diff --git a/CLAUDE.md b/CLAUDE.md index 06aee28b..9aba4b56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (idle or stop) posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and never posted twice, while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 0f05d596..e1a0d7ab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1493,7 +1493,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | Method | Path | Body | Notes | | ------ | ------ | ------ | ------ | | `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. | -| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | | `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. | | `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 70beb5f8..622f2b2e 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -7,7 +7,8 @@ Codeman can post to a private Discord server when a session needs you, give ever ## What it does -- Posts when a session asks for permission, asks a question, goes idle, stops or errors. +- Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything. +- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept, split over several posts if it is long. This reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them. - Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session. - Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress. - Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end. @@ -88,14 +89,15 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push ## Options -| Option | Values | Default | Effect | -| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- | -| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | -| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | -| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | -| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | -| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | -| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | +| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | +| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | ## Telling the CLIs diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index e5484013..976fc0c2 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -38,14 +38,18 @@ only), never in the shared settings, and never shown again once saved. ## Options -| Option | Choices | Default | -| ---------------------------------- | ---------------------------------------- | --------------- | -| Create threads | On the first alert, or for every new tab | First alert | -| When a tab is deleted | Keep, archive or delete the thread | Archive | -| @ ping | Never, critical events only, every post | Critical only | -| Which events | Needs attention, or everything | Needs attention | -| Tell Claude how to reach you | On or off | On | -| Also tell other CLIs, as a message | On or off | Off | +| Option | Choices | Default | +| ---------------------------------------- | ------------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| Stale threads | Keep, archive or delete threads no tab uses | Keep | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell Claude how to reach you | On or off | On | +| Also tell other CLIs, as a first message | On or off | Off | + +A finished turn is posted as the CLI's whole last message, with its formatting and questions kept, split over +several posts if it is long. A shell, and permission or question dialogs, show the last lines of the screen instead. Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a permission prompt, a question, an error, or a post an agent marked urgent. @@ -55,7 +59,7 @@ permission prompt, a question, an error, or a post an agent marked urgent. An agent only posts deliberately if it knows it can: - **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command). -- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle. +- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a first message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle. Restored tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. Tabs that were already open when you turned the bridge on are not told. The alerts still reach diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 29095f94..6c46b0d9 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -26,7 +26,16 @@ import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { probePathKind } from './utils/bounded-path-probe.js'; import WebSocket from 'ws'; -import { WEBHOOK_SCOPES, type WebhookScope, type WebhookUrgency } from './types/push.js'; +import { WEBHOOK_SCOPES, type WebhookUrgency } from './types/push.js'; +import { + DISCORD_ON_DELETE, + DISCORD_PING_MODES, + DISCORD_THREAD_CREATION, + type DiscordConfig, + type DiscordGatewayState, + type DiscordPingMode, + type DiscordResult, +} from './types/discord.js'; const CONFIG_FILE = 'discord.json'; const THREADS_FILE = 'discord-bridge.json'; @@ -44,33 +53,14 @@ const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15); /** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */ const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]); const SNOWFLAKE = /^\d{15,25}$/; - -export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const; -export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number]; -export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const; -export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number]; -export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const; -export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number]; - -export interface DiscordConfig { - enabled: boolean; - token: string; - guildId: string; - parentChannelId: string; - allowedUserIds: string[]; - threadCreation: DiscordThreadCreation; - onSessionDelete: DiscordOnDelete; - pingMode: DiscordPingMode; - /** `attention` skips info-level events (a finished response); `all` sends them too. */ - scope: WebhookScope; - /** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */ - startupHint: boolean; - /** - * Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a - * model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt. - */ - typedHint: boolean; -} +/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */ +const STALE_MIN_AGE_MS = 60 * 60 * 1000; +const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */ +const STALE_MAX_PER_SWEEP = 25; +const DISCORD_EPOCH_MS = 1420070400000; +/** Reading a CLI's transcript must not hold an alert back for long. */ +const FULL_TEXT_TIMEOUT_MS = 4000; export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { enabled: false, @@ -80,6 +70,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { allowedUserIds: [], threadCreation: 'lazy', onSessionDelete: 'archive', + staleThreads: 'keep', pingMode: 'critical', scope: 'attention', startupHint: true, @@ -95,6 +86,11 @@ export interface DiscordMessage { sessionName?: string; /** The tail of what the CLI last printed, so the reader can see what it is asking. */ tail?: string | (() => string); + /** + * The CLI's whole last message, read from its transcript. When it yields text the post carries that + * (split over as many posts as it needs, markdown kept) instead of the screen tail. + */ + fullText?: () => Promise; host?: string; /** A file to upload with the post (already read and size-checked by the caller). */ file?: DiscordFile; @@ -105,17 +101,6 @@ export interface DiscordFile { data: Uint8Array; } -export interface DiscordResult { - ok: boolean; - /** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */ - deduped?: boolean; - status?: number; - error?: string; - at: number; -} - -export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; - // --------------------------------------------------------------------------- // Pure // --------------------------------------------------------------------------- @@ -201,9 +186,12 @@ export function getDiscordStartupHint(): string | undefined { /** * The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and * pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line. + * With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status + * and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option + * (`❯ 1. Yes`), so a question's choices stay. */ -export function screenTail(screen: string, maxLines = 14, maxChars = 900): string { - const lines = screen +export function screenTail(screen: string, maxLines = 14, maxChars = 900, composerGlyph?: string): string { + let lines = screen // eslint-disable-next-line no-control-regex .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '') // eslint-disable-next-line no-control-regex @@ -211,11 +199,60 @@ export function screenTail(screen: string, maxLines = 14, maxChars = 900): strin .split(/\r?\n/) .map((l) => l.trimEnd()) .filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l)); + if (composerGlyph) { + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) { + const row = lines[i].trimStart(); + if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) { + lines = lines.slice(0, i); + break; + } + } + } let out = lines.slice(-maxLines); while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1); return out.join('\n').slice(-maxChars); } +/** + * Split a long message into posts of at most `max` characters, on blank lines first, then lines, then + * (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post + * renders on its own. At most `maxPosts`; the last carries a note when text is left over. + */ +export function splitForDiscord(text: string, max = 1900, maxPosts = 8): string[] { + const posts: string[] = []; + let cur = ''; + let fence = false; + let cut = false; + const flush = () => { + if (cur.trim()) posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd()); + cur = fence ? '```\n' : ''; + }; + const lines = text.replace(/\r\n/g, '\n').split('\n'); + for (let line of lines) { + if (posts.length >= maxPosts) { + cut = true; + break; + } + while (line.length > max - 8) { + if (cur.trim()) flush(); + cur += line.slice(0, max - 8) + '\n'; + line = line.slice(max - 8); + flush(); + if (posts.length >= maxPosts) { + cut = true; + break; + } + } + if (cut) break; + if (cur.length + line.length + 1 > max - 8) flush(); + cur += `${line}\n`; + if (/^\s*```/.test(line)) fence = !fence; + } + if (!cut && cur.trim()) posts.push(cur.trimEnd()); + if (cut && posts.length > 0) posts[posts.length - 1] += '\n*(The message continues in the tab.)*'; + return posts.slice(0, maxPosts); +} + /** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */ export function safeAttachmentName(name: string): string { const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, ''); @@ -279,6 +316,7 @@ function coerce(raw: unknown): DiscordConfig { : [], threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation), onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete), + staleThreads: oneOf(DISCORD_ON_DELETE, r.staleThreads, d.staleThreads), pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode), scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope), startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true, @@ -286,6 +324,17 @@ function coerce(raw: unknown): DiscordConfig { }; } +function withTimeout(p: Promise, ms: number): Promise { + let timer: ReturnType; + return Promise.race([ + p, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timed out')), ms); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); +} + /** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */ class BridgeProblem extends Error {} @@ -345,6 +394,13 @@ async function writeThreadMap( // IO: REST // --------------------------------------------------------------------------- +export interface DiscordThreadInfo { + id: string; + parent_id?: string; + owner_id?: string; + thread_metadata?: { archived?: boolean }; +} + export type DiscordFetch = (target: URL, init: RequestInit) => Promise; export class DiscordHttpError extends Error { @@ -414,6 +470,17 @@ export class DiscordApi { remove(threadId: string): Promise { return this.request('DELETE', `/channels/${threadId}`); } + botUserId(): Promise<{ id: string } | null> { + return this.request('GET', '/users/@me'); + } + /** Active threads of the guild (all channels: the caller filters by parent). */ + activeThreads(guildId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/guilds/${guildId}/threads/active`); + } + /** The most recent archived public threads of a channel (one page). */ + archivedThreads(parentId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/channels/${parentId}/threads/archived/public?limit=100`); + } react(channelId: string, messageId: string, emoji: string): Promise { return this.request( 'PUT', @@ -690,6 +757,8 @@ export class DiscordBridge { private pending = new Map>(); /** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */ private closedWhilePending = new Set(); + /** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */ + private lastFull = new Map(); /** The parent channel the thread map was built under. */ private threadsParent = ''; /** True from boot until the restored tabs exist (see hold). */ @@ -699,6 +768,7 @@ export class DiscordBridge { /** Per-thread chain of inbound messages (see onGatewayMessage). */ private inbound = new Map>(); private mapLoad?: Promise; + private sweepTimer?: ReturnType; /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */ private firstLoad?: Promise; private last: DiscordResult | null = null; @@ -737,6 +807,63 @@ export class DiscordBridge { await this.persistThreads(); } + /** + * Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after + * the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one + * (its map entry may not be written yet), or anything while the map is empty (a lost map would make + * every thread look stale). Returns how many it archived or deleted. Never throws. + */ + async sweepStaleThreads(): Promise { + try { + await this.ensureLoaded(); + const c = this.cfg; + if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId) return 0; + if (Object.keys(this.threads).length === 0 || this.pending.size > 0) return 0; + const me = (await this.api.botUserId())?.id; + if (!me) return 0; + const known = new Set(Object.values(this.threads)); + const cutoff = this.now() - STALE_MIN_AGE_MS; + const stale = (list: DiscordThreadInfo[] | undefined, archived: boolean) => + (list ?? []) + .filter( + (t) => + t.parent_id === c.parentChannelId && + t.owner_id === me && + SNOWFLAKE.test(t.id) && + !known.has(t.id) && + Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff && + // An archived thread has nothing left to archive. + !(archived && c.staleThreads === 'archive') + ) + .map((t) => t.id); + const ids = [ + ...stale((await this.api.activeThreads(c.guildId))?.threads, false), + ...(c.staleThreads === 'delete' + ? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true) + : []), + ].slice(0, STALE_MAX_PER_SWEEP); + let done = 0; + for (const id of ids) { + const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then( + () => true, + () => false + ); + if (ok) done++; + } + return done; + } catch { + return 0; + } + } + + /** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */ + startStaleSweep(): void { + clearInterval(this.sweepTimer); + void this.sweepStaleThreads(); + this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS); + this.sweepTimer.unref?.(); + } + /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */ hold(): void { this.held = true; @@ -790,6 +917,8 @@ export class DiscordBridge { } stop(): void { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; this.gateway?.stop(); this.gateway = null; this.gatewayToken = ''; @@ -798,6 +927,7 @@ export class DiscordBridge { this.lastReply.clear(); this.names.clear(); this.fresh.clear(); + this.lastFull.clear(); this.inbound.clear(); } @@ -886,7 +1016,29 @@ export class DiscordBridge { if (this.lastSent.size > 256) { for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); } + let full: string | undefined; + if (msg.fullText) { + try { + full = (await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS))?.trim() || undefined; + } catch { + full = undefined; // no transcript to read: the screen tail below stands in + } + } + let chunks: string[] = []; + if (full && msg.sessionId) { + if (this.lastFull.get(msg.sessionId) === full) + msg = { ...msg, tail: undefined }; // said already + else { + this.lastFull.set(msg.sessionId, full); + chunks = splitForDiscord(full); + msg = { ...msg, tail: undefined }; + } + } const post = buildPost(msg, this.cfg, explicitUrgent); + // One post when the head and the first part fit together, the rest follow without a ping. + if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) { + post.content += `\n${chunks.shift()}`; + } try { let target = this.cfg.parentChannelId; if (msg.sessionId) { @@ -908,6 +1060,7 @@ export class DiscordBridge { throw err; } } + for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part); return (this.last = { ok: true, at: this.now() }); } catch (err) { return (this.last = this.fail(err)); @@ -985,6 +1138,7 @@ export class DiscordBridge { this.lastReply.delete(sessionId); this.names.delete(sessionId); this.fresh.delete(sessionId); + this.lastFull.delete(sessionId); } /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ diff --git a/src/types/discord.ts b/src/types/discord.ts new file mode 100644 index 00000000..ded66bbc --- /dev/null +++ b/src/types/discord.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Discord bridge type definitions. + * + * The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart + * from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the + * route can import them alone, as the webhook's equivalents live in `push.ts`. + * + * Key exports: + * - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists + * - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret) + * - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports + * + * No dependencies on other domain modules but `push.ts` (the shared scope list). + */ + +import type { WebhookScope } from './push.js'; + +export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const; +export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number]; +export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const; +export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number]; +export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const; +export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number]; + +export interface DiscordConfig { + enabled: boolean; + token: string; + guildId: string; + parentChannelId: string; + allowedUserIds: string[]; + threadCreation: DiscordThreadCreation; + onSessionDelete: DiscordOnDelete; + /** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */ + staleThreads: DiscordOnDelete; + pingMode: DiscordPingMode; + /** `attention` skips info-level events (a finished response); `all` sends them too. */ + scope: WebhookScope; + /** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */ + startupHint: boolean; + /** + * Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a + * model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt. + */ + typedHint: boolean; +} + +export interface DiscordResult { + ok: boolean; + /** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */ + deduped?: boolean; + status?: number; + error?: string; + at: number; +} + +export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; + +/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */ +export type DiscordStatus = Omit & { + hasToken: boolean; + tokenMasked: string; + gateway: DiscordGatewayState; + gatewayError: string | null; + lastResult: DiscordResult | null; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 1af34926..08e30f7f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,7 @@ * | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE | * | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` | * | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` | + * | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` | * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` | @@ -65,6 +66,7 @@ export * from './run-summary.js'; export * from './tools.js'; export * from './teams.js'; export * from './push.js'; +export * from './discord.js'; export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 6a735f5b..dcd2d95f 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js'; export interface SessionPort { readonly sessions: ReadonlyMap; + /** + * The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the + * session routes register, so other server code reads the same per-CLI transcript the viewer does. + */ + lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>; /** * Register a created session with the owner's tab layout. `placement.tabGroupId` * puts it at the end of that group in the same layout write (ignored when the diff --git a/src/web/public/index.html b/src/web/public/index.html index 81c14e39..cb0ae2e6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3278,6 +3278,14 @@ +
+
Stale threadsThreads this bot made that no tab uses any more, checked after startup and then daily. Threads under an hour old are skipped. Use one channel per Codeman.
+ +
@ ping