diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index d757a32e..a36088d3 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -7,7 +7,7 @@ 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. 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 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, OMP, 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`; Codex's permission and question dialogs are also read off its screen. - 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. @@ -89,15 +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 | -| `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 | +| 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. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. 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 445b9d6b..edd2a21b 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -54,9 +54,12 @@ kept, split over several posts if it is long. A shell, and permission or questio 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. +events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, 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. + +The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a +deleted tab's thread leaves the bridge's map. 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 ff62fdd4..840e91ff 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -59,6 +59,9 @@ 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; +/** A Markdown table wider or longer than this is posted as it was written, not padded into a code block. */ +const TABLE_MAX_COLS = 24; +const TABLE_MAX_ROWS = 200; /** Reading a CLI's transcript must not hold an alert back for long. */ const FULL_TEXT_TIMEOUT_MS = 4000; @@ -246,13 +249,38 @@ export function tablesToCodeBlocks(text: string): string { out.push(line); continue; } + // Rows belong to the table while they carry a pipe, and start with one when the header does, so a + // sentence with a pipe under a table stays prose. + const piped = line.trim().startsWith('|'); + let j = i + 2; + while ( + j < lines.length && + lines[j].trim() !== '' && + lines[j].includes('|') && + (!piped || lines[j].trim().startsWith('|')) + ) { + j++; + } const header = cells(line); + // A table past these bounds is left as it was (raw pipes): formatting costs columns times rows, and the + // text can be steered by whatever the agent read. + if (header.length > TABLE_MAX_COLS || j - i - 1 > TABLE_MAX_ROWS) { + for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack + i = j - 1; + continue; + } const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left')); const rows = [header]; - let j = i + 2; - for (; j < lines.length && lines[j].includes('|') && lines[j].trim() !== ''; j++) rows.push(cells(lines[j])); - const cols = Math.max(...rows.map((r) => r.length)); - const width = Array.from({ length: cols }, (_, c) => Math.max(1, ...rows.map((r) => [...(r[c] ?? '')].length))); + for (let k = i + 2; k < j; k++) rows.push(cells(lines[k])); + let cols = 0; + for (const r of rows) if (r.length > cols) cols = r.length; + if (cols > TABLE_MAX_COLS) { + for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack + i = j - 1; + continue; + } + const width = Array.from({ length: cols }, () => 1); + for (const r of rows) for (let c = 0; c < r.length; c++) width[c] = Math.max(width[c], [...r[c]].length); const pad = (v: string, c: number) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c])); const fmt = (r: string[]) => Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c)) @@ -1093,6 +1121,9 @@ export class DiscordBridge { if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) { post.content += `\n${chunks.shift()}`; } + // Claimed just before the first post and given back if it fails, so an alert for the same tab that + // overlaps this one does not post the message too, and a failed post is still sent again. + let claimed: { sid: string; before: string | undefined } | undefined; try { let target = this.cfg.parentChannelId; if (msg.sessionId) { @@ -1101,6 +1132,10 @@ export class DiscordBridge { target = thread; } const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post)); + if (posted && msg.sessionId) { + claimed = { sid: msg.sessionId, before: this.lastFull.get(msg.sessionId) }; + this.lastFull.set(msg.sessionId, posted); + } try { await send(target); } catch (err) { @@ -1114,10 +1149,14 @@ export class DiscordBridge { throw err; } } - if (posted && msg.sessionId) this.lastFull.set(msg.sessionId, posted); + // Parts 2 to 8 are best effort on purpose: the first post, which carries the title, is what is reported. for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part); return (this.last = { ok: true, at: this.now() }); } catch (err) { + if (claimed) { + if (claimed.before === undefined) this.lastFull.delete(claimed.sid); + else this.lastFull.set(claimed.sid, claimed.before); + } return (this.last = this.fail(err)); } } diff --git a/src/web/public/index.html b/src/web/public/index.html index cb0ae2e6..070f8cd3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3279,7 +3279,7 @@