From 021641911ed95957e09c8ee9f54b8745892c2455 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:28:35 +0800 Subject: [PATCH] fix(discord): make the table separator test linear, record a posted message only after it posts, say which CLIs raise a turn end --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 1 + docs/discord-bridge.md | 4 +-- docs/security-architecture.md | 2 +- docs/wiki/Discord-Bridge.md | 15 +++++++--- src/discord-bridge.ts | 38 ++++++++++++++---------- src/web/server.ts | 6 ++-- test/discord-bridge.test.ts | 52 ++++++++++++++++++++++++++++++--- test/discord-push-glue.test.ts | 9 ++++-- 9 files changed, 97 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9aba4b56..5c562526 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`); 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` +**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 (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error 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 not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), 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/architecture-invariants.md b/docs/architecture-invariants.md index 1faf9a9c..5a7d73d2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1375,4 +1375,5 @@ updates `lastSubmitAt` just like any other Enter. - **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded. - **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires. - **Files.** Received files come only from Discord's HTTPS hosts into `/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first. +- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop. - **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`). diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 680d4fb2..d757a32e 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -7,8 +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 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 (a Markdown table, which Discord cannot draw, becomes an aligned code block), 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. +- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex's permission and question dialogs are read off its screen. OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`. +- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It 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. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index e3991939..3c68a096 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder, which stops a file outside that folder being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** each alert sends the rendered screen (whatever the agent printed) to Discord, and with **Which events** set to Everything that is every turn end; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or, only when `typedHint` is on, typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder, which stops a file outside that folder being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or, only when `typedHint` is on, typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 976fc0c2..445b9d6b 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -12,8 +12,9 @@ and it works when you are away from your network. ## What it does -- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop, - each with what is on the tab's screen right now, so you can see what is being asked. +- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A + question or permission prompt shows what is on the tab's screen right now, so you can see what is + being asked; a finished turn or an error carries the CLI's whole last message. - **One thread per tab** in a parent channel, so a reply always reaches exactly one session. - **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter). - **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can @@ -48,8 +49,14 @@ only), never in the shared settings, and never shown again once saved. | 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. +A finished turn, and an error, 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. + +Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which +events** is Everything), and DeepSeek Harness sends a stop. The others (OpenCode, Gemini, Antigravity, Pi, +Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`; Codex's dialogs +are read off its screen. 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. diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index be523876..ff62fdd4 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -90,7 +90,7 @@ export interface DiscordMessage { * 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; + fullText?: () => Promise<{ text: string; version?: string } | undefined>; host?: string; /** A file to upload with the post (already read and size-checked by the caller). */ file?: DiscordFile; @@ -231,7 +231,11 @@ export function tablesToCodeBlocks(text: string): string { .trim() ); }; - const isSeparator = (row: string) => row.includes('|') && /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row); + // Whitespace stripped first, so the pattern has none to backtrack over (a run of spaces made the old one quadratic). + const isSeparator = (row: string) => { + const r = row.replace(/\s+/g, ''); + return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r); + }; const lines = text.split('\n'); const out: string[] = []; let fence = false; @@ -1063,22 +1067,25 @@ 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; + let chunks: string[] = []; + // Remembered only once the first post is out: a failed post must leave the message to be sent again. + let posted: string | undefined; if (msg.fullText) { try { - full = (await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS))?.trim() || undefined; + const got = await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS); + const text = got?.text.trim(); + const sid = msg.sessionId; + if (text && sid) { + // Keyed on the text and the turn it came from, so a later turn that ends on the same words is posted. + const key = `${got?.version ?? ''}\u0000${text}`; + msg = { ...msg, tail: undefined }; + if (this.lastFull.get(sid) !== key) { + chunks = splitForDiscord(tablesToCodeBlocks(text)); + posted = key; + } // else: said already, so only the header goes + } } 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(tablesToCodeBlocks(full)); - msg = { ...msg, tail: undefined }; + // no transcript to read: the screen tail stands in } } const post = buildPost(msg, this.cfg, explicitUrgent); @@ -1107,6 +1114,7 @@ export class DiscordBridge { throw err; } } + if (posted && msg.sessionId) this.lastFull.set(msg.sessionId, posted); for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part); return (this.last = { ok: true, at: this.now() }); } catch (err) { diff --git a/src/web/server.ts b/src/web/server.ts index 253ac36b..810fe27f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3134,13 +3134,15 @@ export class WebServer extends EventEmitter { * segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the * screen tail stands in whenever the reader finds nothing. */ - private async discordFullText(session: Session): Promise { + private async discordFullText(session: Session): Promise<{ text: string; version?: string } | undefined> { if (!this.lastResponseFor) return undefined; // A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither. if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) { return undefined; } - return (await this.lastResponseFor(session)).text; + const r = await this.lastResponseFor(session); + // The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp). + return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') }; } /** The notification body for an event (shared by Web Push and the webhook channel). */ diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 7112342c..1b71c354 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -566,7 +566,7 @@ describe('DiscordBridge', () => { it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => { const b = bridge(); - await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => LONG }); + await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => ({ text: LONG }) }); const sent = posts.filter((p) => p.path.endsWith('/messages')); expect(sent.length).toBeGreaterThan(1); const first = String(sent[0].body?.content); @@ -588,7 +588,7 @@ describe('DiscordBridge', () => { '| kv-f16 | 557.0 |', '| n-gram mod | 550.2 |', ].join('\n'); - await b.notify({ ...MSG, fullText: async () => `Results:\n\n${table}` }); + await b.notify({ ...MSG, fullText: async () => ({ text: `Results:\n\n${table}` }) }); const posted = posts .filter((p) => p.path.endsWith('/messages')) .map((p) => String(p.body?.content)) @@ -600,10 +600,10 @@ describe('DiscordBridge', () => { it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => { const b = bridge(); - await b.notify({ ...MSG, fullText: async () => 'Short answer.' }); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Short answer.' }) }); t += 10_000; posts.length = 0; - await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => 'Short answer.' }); + await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => ({ text: 'Short answer.' }) }); const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); expect(again).toHaveLength(1); expect(again[0]).not.toContain('Short answer.'); @@ -615,6 +615,39 @@ describe('DiscordBridge', () => { b.stop(); }); + it('leaves a message to be posted again when its first post failed', async () => { + let fail = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (fail && path.endsWith('/messages')) return new Response('{}', { status: 500 }); + return jsonRes({ id: 'msg' }); + }); + const first = await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'The answer.' }) }); + expect(first).toMatchObject({ ok: false }); + fail = false; + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: 'The answer.' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('The answer.'); + b.stop(); + }); + + it('posts a later turn that ends on the same words, but not the same turn twice', async () => { + const b = bridge(); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).not.toContain('Done.'); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-2' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('Done.'); + b.stop(); + }); + it('falls back to the tail when reading the transcript throws or hangs', async () => { const b = bridge(); await b.notify({ @@ -1208,6 +1241,17 @@ describe('tablesToCodeBlocks', () => { expect(tablesToCodeBlocks(prose)).toBe(prose); }); + it('converts a hostile separator row of 100,000 spaces in well under a second', () => { + const rows = ['| a | b |', `|---${' '.repeat(100_000)}x`, '| 1 | 2 |'].join('\n'); + const t0 = Date.now(); + expect(tablesToCodeBlocks(rows)).toBe(rows); + expect(Date.now() - t0).toBeLessThan(500); + const real = ['| a | b |', `| --- ${' '.repeat(50_000)}| --- |`, '| 1 | 2 |'].join('\n'); + const t1 = Date.now(); + expect(tablesToCodeBlocks(real)).toContain('```'); + expect(Date.now() - t1).toBeLessThan(500); + }); + it('pads a short row and keeps an escaped pipe in a cell', () => { const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| x \\| y |', '| p | q |'].join('\n')); expect(out).toContain('x | y'); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 4ea07114..0a8d86d1 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -92,12 +92,15 @@ describe('sendPushNotifications -> Discord', () => { it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => { const s = server(); s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' }); - const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two' })); + const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' })); s.lastResponseFor = reader; s.mux = { capturePaneText: () => 'screen\n❯\n status' }; await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' }); const idle = s.discordBridge.notify.mock.calls[0][0]; - expect(await idle.fullText()).toBe('The full answer.\n\n- one\n- two'); + expect(await idle.fullText()).toEqual({ + text: 'The full answer.\n\n- one\n- two', + version: '2026-10-30T00:00:00Z', + }); expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' }); const dialog = s.discordBridge.notify.mock.calls[1][0]; @@ -114,7 +117,7 @@ describe('sendPushNotifications -> Discord', () => { s.mux = { capturePaneText: () => 'screen' }; await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' }); const msg = s.discordBridge.notify.mock.calls[0][0]; - expect(await msg.fullText()).toBe('1. First?\n2. Second?\n3. Third?'); + expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?'); } );