Skip to content

feat(discord): two-way Discord bridge with a thread per tab - #741

Open
opticon454 wants to merge 24 commits into
Ark0N:masterfrom
opticon454:feature/discord-bridge
Open

opticon454 wants to merge 24 commits into
Ark0N:masterfrom
opticon454:feature/discord-bridge

Conversation

@opticon454

@opticon454 opticon454 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Context

An agent that is blocked on something only a person can do (press a reset button, answer a question, approve a prompt, notice a wait that timed out) currently says so in its terminal and stops. If you are away from the dashboard, nothing reaches you: browser notifications need an open tab and do not work off-network without a VPN, and the existing webhook is one-way, so you can hear about it but not answer.

This adds an opt-in, two-way Discord bridge. Codeman posts to a private Discord server when a session needs you, gives every tab its own thread, and types your reply in that thread into that tab. It only makes outbound connections (Discord's REST API and gateway WebSocket), so nothing is exposed to the internet and it works from anywhere Discord does.

It is off by default and changes nothing for anyone who does not turn it on: no new launch argument, no new message, no new network connection until the bridge is enabled in Settings.

Settings
A tab's thread (first version, with the screen tail): an alert, a reply typed into the tab, and an agent's own post

What it does

  • Alerts for the events Web Push already carries (permission prompt, question, idle, error, blocked respawn, crash loop), posted into the tab's thread. A finished turn posts the CLI's whole last message; a dialog posts the screen. See How the alert text is built below.
  • Stale thread cleanup (staleThreads, off by default): after boot and then daily, archive or delete threads the bot made that no tab uses. It skips a thread under an hour old, one someone else made and any run with an empty thread map, and closes at most 25 per run.
  • One thread per tab under a parent channel, so a reply reaches exactly one session. Threads are created on the first alert or for every new tab; a deleted tab's thread is kept, archived or deleted; pings are off, critical only or every post. All three are settings.
  • Replies from an allowlisted Discord user are typed into that tab as one printable line plus Enter (capped, one per second per tab), with a tick reaction as the receipt.
  • Agents can ask for you. codeman notify "<message>" [--urgent] [--file <path>] posts to the tab's thread (POST /api/sessions/:id/discord-notify). Claude is told about it through --append-system-prompt (setting startupHint, on). Other CLIs are told only if you turn on the separate typedHint setting (off by default): one short message typed the first time a brand-new local tab goes idle, never to shell, restored, remote or Docker tabs.
  • Files both ways. A file attached in a thread is saved under <tab folder>/.codeman-discord/ and its path is given to the agent. --file uploads a file from inside the tab's folder.

How the alert text is built

The first version posted the last 14 lines of the tmux screen in a code box. Used for real that turned out badly: a long answer started mid-sentence, the box carried the prompt row and the status lines under it (❯, the model and token line, bypass permissions on), and a Markdown table showed as raw pipes. So the text of an alert now depends on the event:

Event What is posted
A finished turn (idle, stop), an error The CLI's whole last message as Markdown: bold, lists, numbered questions and code fences kept.
A permission prompt or a question dialog The screen, because the options are only there. The ❯ 1. Yes selected row is never cut.
A shell tab The screen (a shell has no turns).
Nothing readable (no transcript, no pane) The screen, as before.
  • Where the whole message comes from. The response viewer's reader, so it is the same per-CLI source the viewer uses: the CLI's own transcript for Claude, Codex, DeepSeek and OMP, and the pane split into turns for OpenCode, Gemini, Antigravity, Pi, Grok and Copilot (which can start mid-message when a long answer has scrolled off). To share it without a second copy, the body of GET /api/sessions/:id/last-response moved into a lastResponseFor(session, context) function in session-routes.ts; the route is a thin wrapper over it and behaves exactly as before. The route registers the function on its context and WebServer hands it to the bridge. Which CLIs qualify is registry data (capabilities.transcript, isExternalCliTranscriptMode), never a CLI id.
  • Which CLIs raise a turn end. The trigger is /api/hook-event, and only some CLIs send it: Claude Code sends stop (an info event, posted only with Which events on Everything) and idle_prompt (a warning, so it posts by default once a turn has waited a minute); DeepSeek Harness sends only stop. Codex, OMP and the other CLIs declare hooks: 'none' (or send none), so their last message reaches Discord with an error, a blocked respawn, a crash-loop alert or codeman notify; Codex's dialogs are read off its screen. The per-CLI test proves the reader, not the trigger.
  • Long messages are split, not cut: on blank lines, then lines, then (for one huge line) by length, into posts of at most 1,900 characters and at most 8 of them. A code fence the cut lands inside is closed and reopened, so each post renders on its own. Only the first post carries the title and the ping.
  • Tables. Discord does not draw Markdown tables. Each table outside a code fence (at most 24 columns and 200 rows: a larger one is posted as written, so a crafted message cannot cost more than its length) becomes a code block with its columns padded to the widest cell, right-aligned where the separator row says ---:, with bold and backtick marks dropped from cells (a code block would show them literally). Tables inside a fence, and prose that merely contains pipes, are left alone.
  • Screen fallback. The composer row and everything under it are cut, but only within the last 8 rows and only for a finished turn, using the CLI's own promptGlyph from the registry. A dialog keeps its ❯ 1. option rows.
  • The same text is not posted twice (an idle alert followed by a stop alert, or a repeat of the same event): the repeat posts only its header. Reading the transcript is bounded to 4 seconds, and a failure or an empty read falls back to the screen.
  • Failure text. A post that cannot be made says why in Settings and in codeman notify ("Discord did not return a thread", "The tab was closed") instead of a generic "Network error".

How

  • src/discord-bridge.ts is split like webhook-notify.ts: pure helpers, a 0600 store, then IO (DiscordApi REST with an injected fetch, DiscordGateway inbound with an injected socket, DiscordBridge rules). No new dependency (ws was already one).
  • Outbound hangs off WebServer.sendPushNotifications next to the webhook, so it works with zero push subscriptions, and tab events hang off WebServer.broadcast.
  • The startup hint rides the CLI registry as one new engine value, appendSystemPrompt, undefined unless the bridge and its setting are on, so every launch command is byte-identical otherwise (the golden spawn tests are untouched).
  • src/cli-notify.ts is the codeman notify command, testable like cli-agent.ts. discord-notify also accepts the 8-character id a Docker case exports.
  • Routes follow webhook-routes.ts: GET/PUT /api/discord, POST /api/discord/test, admin-only in multi-user mode.

Safety

  • The bot token is stored in ~/.codeman/discord.json (0600, tmp+rename), never returned, logged or placed in an error, and is on the config-backup redaction list.
  • A reply is acted on only if the author is in allowedUserIds, the guild matches, and the channel is a thread the bridge created. Everything else is dropped unread. Bots are ignored.
  • Posts send allowed_mentions limited to the allowlisted ids, so agent output cannot ping a server.
  • Attachments are fetched only from Discord's own HTTPS hosts (redirects not followed), at most 5 of 8 MB, names reduced to [A-Za-z0-9._-]. A file an agent sends must be a regular file whose real path (symlinks followed) is inside its own tab's folder, so it cannot post discord.json or a key. Remote sessions send and receive no files.
  • A Discord outage, a bad token or a failed download never reaches a session: every path catches and backs off.
  • The bot needs only: View Channels, Send Messages, Send Messages in Threads, Create Public Threads, Read Message History, Add Reactions (plus Attach Files and Embed Links for files and previews, and Manage Threads only to delete threads), with Message Content Intent as the one privileged intent. docs/discord-bridge.md is a step-by-step least-privilege setup; docs/wiki/Discord-Bridge.md is the user-facing page.

Verification

  • Full npm test on this branch: 671 files passed, 1 skipped, 0 failed (13,671 tests). Typecheck, lint and format:check clean.
  • New tests: test/discord-bridge.test.ts (pure rules, store, REST client, gateway state machine with a fake socket, the bridge's gates, files, startup hint, screen tail, the message splitter, the table conversion, the stale-thread sweep), test/routes/discord-routes.test.ts, test/cli-notify.test.ts (including the real transport against a local server), test/discord-push-glue.test.ts (the WebServer wiring, with no push subscription) test/discord-startup-hint.test.ts (the launch argument is present only while enabled) and test/routes/discord-last-message-per-cli.test.ts, which drives the real last-response route with a message of three numbered questions for Claude (JSONL transcript), Codex (rollout), OpenCode, Gemini, Antigravity, Pi (pane) and Copilot (its own screen dialect), and checks that every question comes back whole, in order, and survives the split into posts. DeepSeek, OMP and Grok use the same wiring but are not covered with that fixture.
  • Run for real against Discord on a live instance: an alert with a clean screen tail, a reply typed into the tab (tick reaction), codeman notify with a ping, a file received into the tab folder, and a markdown file sent with --file that contained a link, a finished turn posted whole with its numbered questions and no prompt row or status lines, and a Markdown table posted as an aligned code block.

Notes for review

  • This is one feature in layers, and the layers share the gateway, the thread map and the allowlist, so it is hard to split further without scaffolding. Happy to split off the file transfer and the startup hint if you prefer.
  • No changeset or CHANGELOG.md change, per CONTRIBUTING.md. Suggested release note: "A two-way Discord bridge: answer a blocked agent from your phone, in a thread per tab, with files both ways and codeman notify for agents that need you."
  • Not covered by automated tests: the Settings panel (no browser test) and a real Discord round trip (checked by hand, above).

Commits

  • 61bf26c feat(discord): two-way Discord bridge with a thread per tab
  • 1c4338e feat(discord): codeman notify, startup hint for every CLI, and file send/receive
  • dcf51b1 fix(discord): post the rendered screen, not the raw stream, as the alert tail
  • e3ca07d docs(discord): restore api-reference.md formatting, keep only the Discord section
  • 902d449 feat(discord): testable codeman notify, short-id route, wiki page, docs and glue tests
  • aedefab chore(discord): drop the changeset, the maintainer writes release notes
  • eb0553a fix(discord): address review on feat(discord): two-way Discord bridge with a thread per tab #741
  • e95d38a fix(discord): load the thread map once, so alerts that race at startup keep their threads
  • 6d43575 feat(discord): make the typed first-message hint a separate opt-in, off by default
  • 2d4036c test(discord): give the bare cleanup server the bridge its delete path now calls
  • 0db9b55 fix(discord): rate limit discord-notify, bound the folder probe, stop sendTest swapping the live config
  • 1ab54b9 fix(discord): report a refused post as a failure, bound the file probe, drop threads of a previous channel
  • dc22519 fix(discord): discard a thread created for a replaced parent channel, test the notify timeout, document the bridge invariants
  • 212989b fix(discord): digit replies without Enter, start the gateway after restore, hide other users' session ids, fix docs
  • 72a59de docs(discord): write down the lone-digit rule, which installs have codeman, and tidy comments
  • b68792f fix(discord): say when the agent has exited, keep replies in order, settle a stalled response, prune dead tabs
  • a698729 fix(discord): keep the thread of a discarded rebuild, close one made for a closed tab, unique tmp names, wider prune keep set
  • d044e49 fix(discord): show the real reason a thread fails, keep the gateway across a save, refund the rate limit for a refused file
  • 9f18b78 fix(discord): replace a gateway that gave up when the same config is saved again
  • 97c698b fix(discord): ignore gateway frames after stop, hold the typed hint while a choice is pending, name the Discord group in the settings reference
  • d6c7d30 fix(discord): tidy a thread made in the old channel, keep the two pending sets in step, settle the hint when the settings are off
  • 093f242 feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types
  • e5d82eb feat(discord): post a Markdown table as an aligned code block
  • 0216419 fix(discord): make the table separator test linear, record a posted message only after it posts, say which CLIs raise a turn end
  • c5d0e4b fix(discord): bound the table conversion, claim a message before posting it, list every CLI without a turn end

Alerts for permission prompts, questions, idle and errors post into a per-tab thread; a reply from an allowlisted user is typed into that tab. Thread creation, thread cleanup on tab delete and pinging are configurable. POST /api/sessions/:id/discord-notify lets a CLI ask for the user. Setup is in docs/discord-bridge.md.
…end/receive

Adds the codeman notify command, a startup hint (Claude via --append-system-prompt, other CLIs once on first idle), files saved from thread attachments into the tab folder, and --file uploads limited to the session folder.
…ert tail

A full-screen UI repaints in place, so the raw output stream read as garbage. Read capture-pane instead and drop rules, blanks and escape codes.
…cord section

A formatter run re-padded every table in the file and broke the cron docs test, which matches the original spacing.
…cs and glue tests

Moves the notify command into src/cli-notify.ts with tests, lets discord-notify take the 8-character id a Docker case exports, adds the wiki page and index entries, rewrites the stale changeset and security notes, and pins the WebServer wiring.
@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you for this. It adds an opt-in, two-way Discord bridge: alerts go to a thread per tab, an allowlisted reply in that thread is typed into the tab, agents can ask for you with codeman notify, and files travel both ways. The layering after webhook-notify.ts, the 0600 token store, the allowlist and guild gates, allowed_mentions and the tests are careful work, and CI is green.

A few things need fixing before this can merge.

Must fix

  1. src/web/server.ts:3012: the alert tail is computed for every push event before the bridge checks whether it is enabled. discordTail() calls capturePaneText(), which is a synchronous tmux capture-pane, and hook:stop fires at the end of every Claude turn, so every install now runs an extra sync exec on the event loop per turn, bridge on or off. Please build the tail only when the bridge will post (for example, pass it as a function that notify() calls after shouldSendDiscord and the dedupe).
  2. src/web/server.ts:515-519 with src/discord-bridge.ts:774-779: the startup hint goes through the same sendInput as a human reply, which writes with fromUser: true. With Auto-named sessions on, a new codex or opencode tab is then named after the hint (w3-app: Codeman note: if you are blocked...) and the user's real first prompt no longer names it. CLAUDE.md (Auto-named sessions) reserves fromUser for input a person typed. Please give sendInput a fromUser argument: true for Discord replies, false for the hint. Please also add the Discord reply path to the list of fromUser writers in that CLAUDE.md paragraph.
  3. src/discord-bridge.ts:702, src/web/routes/discord-routes.ts:176, src/cli-notify.ts:101: notify() returns null both when the bridge is off and when a message is deduped. A second, different codeman notify from the same tab within 3 s gets 409 The Discord bridge is not enabled, and the CLI tells the agent "The Discord bridge is off" with exit 0, so the message is lost and the agent stops trying. A route test reproduces it: first call 200, second call 409. discord-notify is a deliberate message, so please skip the dedupe for it (or dedupe on the text) and give "deduped" and "disabled" separate answers.

Should fix

  1. src/discord-bridge.ts:765: docs/discord-bridge.md:104 and docs/wiki/Discord-Bridge.md:58 say resumed tabs never get the typed hint, but sessionCreated() only looks at createdAt, and a resumed tab is created now. resumeSessionId is already in the session:created payload, so the check can use it; otherwise the docs need correcting.
  2. src/discord-bridge.ts:774: the typed hint also goes to remote (SSH) and Docker external sessions. codeman is not on a remote host and not in the agent image, so the agent is told to run a command it does not have. Please skip those sessions.
  3. src/discord-bridge.ts:790 and src/web/server.ts:4372 (CLAUDE.md, Memory leaks: clear Maps in stop()): sessionDeleted() returns before it clears names and lastReply when a tab has no thread, and fresh is never cleared, so both grow by one entry per tab for the life of the server, with the bridge off too. WebServer.stop() never calls discordBridge.stop(), so the gateway socket and its heartbeat interval outlive the server.
  4. src/web/server.ts:2756: a detach (DELETE /api/sessions/:id?killMux=false) also broadcasts session:deleted, so the bridge archives or deletes the thread of a session that is still running and can be re-attached. Please act only on real deletes (for example from _doCleanupSession when killMux is true, as agentInbox.drop does).
  5. src/discord-bridge.ts:276: discord-bridge.json is written from automatic paths (several thread creations at once) through one shared tmp file name, so two writes can interleave. A torn file reads back as {} at the next start, and every reply in an existing thread is then dropped. One promise chain for these writes fixes it.
  6. src/web/routes/config-backup-routes.ts:234: discord.json is now in config backups, but restoring it does not reach the running bridge, which keeps its cached config. CLAUDE.md (Config backups) asks every restore to run its reload in afterRestore(), as intents.json and clis.json do; please reload the bridge there or add the file to RESTART_REQUIRED.
  7. src/discord-bridge.ts:811-848: please give .codeman-discord/ the same safeguards as the prompt uploads folder: probe the workspace with probePathKind(dir, { pastCap: true }) before the first disk touch (CLAUDE.md, rule fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516) and write a .gitignore of * into it once with wx, so an agent's git add -A does not commit what was sent to it.
  8. docs/security-architecture.md: please add that alerts send the rendered screen to Discord (whatever the agent printed, on every turn end with "Everything" selected), and that in multi-user mode an allowlisted Discord account can type into every user's sessions. The sentence that a CLI "cannot post discord.json or a key" also says more than the check does: the agent runs as the same OS user and can paste a file's contents into the message, so the check prevents accidental uploads.

Small things

  • src/discord-bridge.ts:860: a second reply within one second is dropped with no reaction, so the person cannot tell it was not typed.
  • src/discord-bridge.ts:836: please validate m.id as a snowflake before it goes into a file path.

Open on my side

Two design questions are still open and not decided yet: whether non-Claude CLIs get the startup hint as a typed first message at all (it spends a turn on "Reply with just: ok", and on an agent-spawned worker or a cron session it lands on the same first idle as the first real prompt), and whether it defaults on. Items 2, 4 and 5 apply only if the typed hint stays.

With the must-fix and should-fix items addressed and those questions settled, this is ready to merge.

Builds the alert tail only for a post that goes out; separates deduped from disabled and never dedupes a deliberate notify; types the startup hint with fromUser false and skips resumed, remote and Docker tabs; clears per-tab maps and closes the bridge on stop; acts only on a real delete, not a detach; serialises thread-map writes; reloads the bridge after a discord.json restore; probes and self-ignores .codeman-discord; validates the message id; reacts when a reply is rate limited; documents what leaves the machine.
…ff by default

Claude keeps the silent system-prompt hint (startupHint, on). Other CLIs are sent the typed message only with typedHint, which costs a model turn per tab.
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you for the careful review. All of it is addressed in the commits since aedefab8 (eb0553a6, e95d38a9, 6d435750, 2d4036cf). The full npm test is green on the tip (670 files, 13,602 tests).

Must fix

  1. Tail built for every event. tail is now a function that notify() calls only after the bridge is enabled and the post is not a duplicate, so there is no tmux exec per turn while the bridge is off. A test pins that it is not called when disabled or deduped, and the server glue test pins that the capture does not run until the function does.
  2. Typed hint named the tab. sendInput now takes fromUser: true for a Discord reply, false for the hint. I added the Discord reply path to the fromUser writers list in the CLAUDE.md Auto-named sessions paragraph.
  3. Second codeman notify lost. notify() takes { urgent, deliberate }. A deliberate post is never deduped. An automatic event that is deduped now answers { ok: true, deduped: true }, which is not the null that means "bridge is off". Your two-calls-within-3-seconds case is a route test now (both 200).

Should fix

  1. Resumed tabs: session:created now passes resumeSessionId, and a resumed tab is never marked fresh. Test added.
  2. Remote (ssh) and Docker tabs are skipped for the typed hint (isHintableSession, tested for codex local, remote, Docker, Claude, shell).
  3. sessionDeleted() clears names, lastReply and fresh before the early return, stop() clears them, and WebServer.stop() now calls discordBridge.stop() (closes the gateway socket and its heartbeat). Tests for both.
  4. A delete now reaches the bridge from _doCleanupSession when killMux is true (next to agentInbox.drop), and from the half-built-session error path. session:deleted no longer triggers anything, so a detach leaves the thread alone. Tested both ways.
  5. Thread-map writes go through one promise chain. While testing this I found a second race that was real: several alerts arriving before the first load each re-read the map, and the last read replaced threads made in between. The first load is now shared. A test makes four threads at once and reads all four back.
  6. registerConfigBackupRoutes takes a reloadDiscord dep and afterRestore() calls it when discord.json was restored. Test added.
  7. .codeman-discord/ is probed with probePathKind(dir, { pastCap: true }) first, refused if it is a symlink or not a directory, and self-ignores with a * .gitignore written once with wx. Files are written with wx too. Tests cover the folder, an existing .gitignore, a symlink and a missing workspace.
  8. docs/security-architecture.md now says that alerts send the rendered screen to Discord (every turn end with "Everything"), that in multi-user mode an allowlisted account can type into every user's sessions, and that the path check prevents accidental uploads, not a determined agent running as the same user. The wiki page and docs/discord-bridge.md say the same.

Small things. A reply sent within a second of the last one now gets a ⏳ reaction instead of vanishing, and m.id must be a snowflake before it goes into a file name (the reply says so).

Your two open questions. I made the typed first-message hint its own setting, typedHint, off by default, so by default it does not spend a turn and does not land on a spawned worker's or cron session's first idle. Claude still gets the hint silently through --append-system-prompt (startupHint, on). Both are separate switches in Settings. If you would rather drop the typed hint entirely, that is one commit and I am happy to do it.

@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you for working through the whole list so quickly. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge. Alerts go to a thread per tab, an allowlisted reply in that thread is typed into the tab, agents can ask for you with codeman notify, and files travel both ways.

I checked every item from my last review against the code. Items 1 to 3 and 5 to 11 and both small things are in, and each one has a test. Item 4 is the exception, and there are a few small new things.

Should fix

  1. src/web/server.ts:2769: the resumed check reads the top-level resumeSessionId, which only a Claude create sets (src/web/public/terminal-ui.js:3912). Codex resumes through codexConfig.resumeSessionId, opencode, pi, grok, copilot and omp through their config's continueSession, and DeepSeek through deepSeekConfig.resumeSession (terminal-ui.js:3877-3901). So with typedHint on, a resumed codex or opencode tab still gets the hint, while Claude, the one mode the check catches, never gets the typed hint anyway. The tests pass resumed=true directly (test/discord-bridge.test.ts:725) or a hand-built payload (test/discord-push-glue.test.ts:114), so they do not see it. Please decide "resumed" from the live Session using those per-CLI fields, without branching on a CLI id (CLAUDE.md, CLI registry), and add a glue test that creates a real codex session with codexConfig.resumeSessionId. The alternative is to take "resumed" out of docs/discord-bridge.md:105, docs/wiki/Discord-Bridge.md:59, the Settings text at src/web/public/index.html:3299 and the CLAUDE.md paragraph.
  2. src/discord-bridge.ts:823-846: sendTest() swaps this.cfg for the duration of the post and restores the old value in finally. A save that lands in that window (Remove token from a second tab, another admin, a config restore) is undone when the test returns. I reproduced a disabled bridge that kept posting alerts with the removed token, and the same path keeps a just-removed user on the allowlist. Please give sendTest its own new DiscordApi(() => cfg.token, this.deps.fetchImpl, this.deps.sleep) and leave this.cfg alone.
  3. src/web/routes/discord-routes.ts:149: with dedupe off for discord-notify (as it should be), nothing caps it. A looping or prompt-injected codeman notify --urgent pings the allowlisted phones until Discord answers 429, and each --file call holds up to 8 MB in memory with no in-flight bound. In multi-user mode any session owner can do this to the admin's server. Please add a per-session token bucket on the route (a few per minute, like consumePasteToken on paste-image) that answers 429 RATE_LIMITED, and have src/cli-notify.ts print that as a plain "too many messages" line.
  4. src/web/routes/discord-routes.ts:72: fileProblem starts with an unbounded realpath(workingDir) on a request path. Please probe with probePathKind(session.workingDir, { pastCap: true }) first and refuse anything but a directory through describeUnknownPath(), as paste-image does (CLAUDE.md, user-chosen paths are probed bounded, fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516).

Small things

  • src/discord-bridge.ts:875: names starts empty, so the first session:updated after a restart, and the sessionRenamed that follows an onSessionStart creation, both PATCH a thread to the name it already has. Discord limits renames tightly, so these use up what a real rename (auto-name on the first prompt) needs. Recording the name on first sight without a PATCH fixes it. The test at test/discord-bridge.test.ts:551 clears posts after that first call, so it does not catch this.
  • src/web/server.ts:1359: boot calls reload() directly, so firstLoad stays unset and the first session event starts a second reload() (two gateways, the first stopped before it identifies). Booting through ensureLoaded() avoids it.
  • src/discord-bridge.ts:759: when createThread answers without an id, notify() returns the shared this.last, which is null (the route then answers 409 "not enabled" and the CLI tells the agent the bridge is off) or an earlier success. Returning the failure from threadFor fixes it.
  • src/web/routes/discord-routes.ts:53: resolveParentSessionId() in src/web/route-helpers.ts already resolves a unique 8-character prefix, owner-scoped, so resolveSessionId can reuse it.

Still open on my side

Whether the typed first-message hint (typedHint, now off by default) stays in this PR or comes out is still undecided. If it comes out, item 1 goes with it.

None of this blocks. You are welcome to push the four items above; whatever is still open at merge time is small enough to apply on top.

… sendTest swapping the live config

- discord-notify: per-session token bucket (6 a minute), 429 RATE_LIMITED, plain line from codeman notify
- fileProblem probes the working folder with the bounded probe before realpath
- sendTest uses its own DiscordApi and leaves this.cfg alone
- drop the resumed-tab check from the typed hint (only Claude's field was read)
- first sessionRenamed per session records the name without a PATCH
- boot goes through ensureLoaded, so one gateway starts
- a thread created without an id reports the failure
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all four and the small things are in. The full npm test is green on the new tip.

Should fix

  1. Resumed. You are right that the check only saw Claude's top-level resumeSessionId, and Claude never gets the typed hint anyway. Rather than read six CLIs' resume fields, I took the second option you offered: "resumed" is gone from sessionCreated, bridgeSessionEvent, the tests, docs/discord-bridge.md, the wiki page, the Settings text and the CLAUDE.md paragraph. A resumed tab can now get the typed hint on its first idle, which is harmless while typedHint is off by default. The remaining guards are unchanged: restored tabs (created more than 60 s ago), shell, remote and Docker.
  2. sendTest swapping this.cfg. It now builds its own DiscordApi from the config it is given and never touches this.cfg. A test pins that the live token is still the saved one after a test post.
  3. No cap on discord-notify. A per-session token bucket on the route: 6 a minute, refilled continuously, answering 429 RATE_LIMITED with Retry-After: 60. codeman notify prints "Too many messages to Discord from this tab. Wait a minute, then send one." Tests cover the 7th and 8th call and the CLI line. The docs, API reference and CLAUDE.md say so.
  4. Unbounded realpath(workingDir). fileProblem now probes the folder with probePathKind(workingDir, { pastCap: true }) first and refuses anything but a directory through describeUnknownPath(). A route test covers a working folder that is not a directory.

Small things

  • Rename on first sight. sessionRenamed now records the name without a PATCH the first time it sees a session. The test no longer clears posts before checking.
  • Double reload() at boot. Boot goes through ensureLoaded() (now public), so the first session event shares that load.
  • Thread without an id. threadFor records a failure (Discord did not return a thread) in last before returning null, so notify() returns the failure, not null or an older success. Tested.
  • resolveParentSessionId. I left resolveSessionId as it is. The helper requires the target's owner to equal an owner argument, and here the caller is an admin or the owner and the target's owner is not known until the session is found, so reusing it would either reject an admin or need the same lookup first. Happy to change it if you see a clean way.

On typedHint: still your call. Dropping it is one commit and takes the isHintableSession and sessionIdle paths with it.

@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you for working through the last round so quickly, and for the clear write-up of each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked every item from the last round against the code, and each one is in and tested: sendTest has its own client, discord-notify is rate limited with a clean CLI message, the workspace probe is bounded, a first-sight rename records without a PATCH, boot goes through ensureLoaded(), and "resumed" is gone from code and docs. Typecheck, lint, format, frontend syntax and the touched test files are green.

Must fix

  1. src/cli-notify.ts:99 with src/web/routes/discord-routes.ts:217: when Discord refuses or fails the post, the route answers 200 with data.ok: false (correct, and the same shape as /api/discord/test), but runNotify treats every 2xx as success. With a token Discord refuses, the route answers 200 {"success":true,"data":{"ok":false,"status":401,"error":"The bot token was refused"}} and codeman notify prints Posted to Discord with exit 0. The same happens for a 403, a timeout, a network error or a failed thread creation. A blocked agent then believes the person was reached and waits. Please have postJson also return data.ok and data.error, and have runNotify exit 1 with Could not post to Discord: <error> when ok is false. Please add a CLI test that fakes a 200 with ok: false (today fake(status, error) never sends a body with data.ok) and a route test with a fetch that refuses (test/routes/discord-routes.test.ts:231 only checks ok: true), and say in docs/api-reference.md that a 200 carries the delivery result in data.ok.

Should fix

  1. src/web/routes/discord-routes.ts:100: the folder probe is bounded now, but realpath(file) still runs unbounded on whatever absolute path the agent passed, before the confinement check, so --file /mnt/dead-mount/x holds a libuv worker (CLAUDE.md, user-chosen paths are probed bounded, fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server #516). Please probe the file first with probePathKind(file, { pastCap: true }): refuse unknown through describeUnknownPath() and anything other than 'file' as not a regular file.
  2. src/discord-bridge.ts:692-698 and :810: the thread map is kept across a change of guild or parent channel. After pointing the bridge at a new channel, every tab that already had a thread keeps posting into the old one; if the guild changed, replies there are dropped by the guild check, and if the bot lost access to the old channel the post fails with 403, which is not retried (only a 404 is), so that tab never gets a thread in the new channel. Please record the parent channel with each thread entry (or the guild and channel the map was built for) and drop entries that no longer match in reload(), with a test that changes parentChannelId and expects a new thread.
  3. src/cli-notify.ts:38: postJson duplicates httpRequest from src/cli-agent.ts:313 (which the MCP server also reuses) but without its 30 s timeout, so a server that accepts the connection and never answers leaves codeman notify hanging. Reusing httpRequest, or adding a timeout that resolves status 0, fixes it.

Small thing

  • src/discord-bridge.ts:760 and :802: threadFor reports a failure through the shared this.last, and notify() reads it back after an await, so a concurrent post that succeeds in between makes the failed call report ok: true. Returning the failure from threadFor (for example string | DiscordResult) removes the shared read.

Still open on my side

Whether the typed first-message hint (typedHint, off by default) stays in this PR or comes out is still undecided.

Item 1 needs to be in before this merges. Items 2 to 4 and the small thing are welcome in the same push; whatever is still open at merge time is small enough to apply on top.

…e, drop threads of a previous channel

- codeman notify exits 1 when the route answers 200 with data.ok false
- notify request times out at 30 s instead of hanging
- fileProblem probes the file with the bounded probe before realpath
- the thread map records its parent channel; a new channel starts afresh
- threadFor returns its failure instead of sharing this.last
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all five are in. The full npm test is green on the new tip (670 files, 13,611 tests).

Must fix

  1. codeman notify reported success when Discord refused. postJson now also returns data.ok and data.error, and runNotify exits 1 with Could not post to Discord: <error> when ok is false. A CLI test fakes a 200 with ok: false (and one with ok: true), a route test uses a fetch that answers 401 and checks the 200 carries data.ok: false, and docs/api-reference.md now says a 200 carries the delivery result in data.ok.

Should fix

  1. Unbounded realpath(file). fileProblem probes the file first with probePathKind(file, { pastCap: true }): unknown goes through describeUnknownPath(), anything other than a file is "Not a regular file". Tested with a directory.
  2. Thread map kept across a channel change. discord-bridge.json now records the parent channel it was built under ({ parentChannelId, threads }; the older flat file is still read). reload() drops the map when the parent channel differs, so every tab gets a thread in the new channel. Tests change parentChannelId and expect a new thread, and check the threads survive a restart under the same channel. Only the parent channel is recorded, not the guild: a channel id belongs to one guild, so a guild change implies a channel change.
  3. No timeout. I kept postJson and added a 30 s timeout that destroys the request and resolves status 0 (the CLI prints "Could not post to Discord"). I did not reuse httpRequest because it needs an AgentContext and rejects instead of resolving; say if you would rather share it.

Small thing

  • threadFor now returns string | DiscordResult, and notify() returns that failure itself, so a concurrent success can no longer overwrite it. A test runs a failing thread creation beside a succeeding post.

typedHint is still your call: one commit to remove it.

@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you for working through the last round so quickly, and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked every item from the last round against the code, and each one is in and tested: codeman notify exits 1 with Discord's error when the route answers data.ok: false, fileProblem probes the file with the bounded probe before realpath, the thread map records its parent channel and starts afresh on a new one, the request has a 30 s timeout, and threadFor returns its own failure. Typecheck, lint, format, frontend syntax and the touched test files are green. I also ran postJson against a server that never answers: it gives up after 30 s and the CLI reports that it could not post, as intended.

Nothing left blocks. A few small things remain, and I can apply them on top at merge time unless you would rather push them yourself:

  1. src/cli-notify.ts:61: the timeout has no test. Making it an optional parameter of postJson (default 30 000 ms) lets test/cli-notify.test.ts pin it against a port-0 server that never answers, with a short timeout.
  2. src/discord-bridge.ts:719-724 with :784-792: a Settings save that changes the parent channel while a thread is being created does not stop that creation. It still records a thread from the old channel in the new map, and its finally can delete a newer pending creation for the same tab. Capturing the parent channel when the creation starts and dropping the result if it no longer matches, and deleting from pending only while the entry is still this promise, closes both.
  3. src/discord-bridge.ts:313: the flat discord-bridge.json layout only existed in earlier commits of this PR, so the fallback that reads it, and the threadsParent !== '' case in reload() that keeps its threads, can go.
  4. docs/architecture-invariants.md has no Discord bridge section yet. A short one built from the CLAUDE.md paragraph would match how the other subsystems are documented.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR is not decided yet.

Once that question is settled, this is ready to merge, with the small items above going in on top if they are not in by then. Thank you again for this feature and for the care you have put into every round.

… test the notify timeout, document the bridge invariants

- a thread creation that finishes after the parent channel changed is dropped, and a finished creation only clears its own pending entry
- postJson takes an optional timeout, pinned by a test against a server that never answers
- drop the flat discord-bridge.json fallback
- add a Discord bridge section to architecture-invariants
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, I took all four on this push (the full npm test is green: 670 files, 13,613 tests).

  1. Timeout test. postJson takes an optional timeoutMs (default 30 000), and a test pins status 0 against a port-0 server that never answers, with a 100 ms timeout.
  2. Parent channel changing mid-creation. threadFor captures the parent channel when the creation starts and discards the result if it no longer matches (returned as a failure), and its finally only clears the pending entry while it is still this creation's. A test holds a creation open, changes the channel, releases it and expects no thread in the new map.
  3. Flat layout fallback. Removed, along with the threadsParent !== '' case in reload().
  4. docs/architecture-invariants.md. A new "Discord bridge" section built from the CLAUDE.md paragraph.

typedHint is still your call; removing it is one commit.

@opticon454

Copy link
Copy Markdown
Contributor Author

On typedHint: I would like it to stay in this PR. The goal of the feature is that every CLI Codeman runs can reach the person, not only Claude, and the typed first message is the only automatic way to tell a Codex, OpenCode or similar tab about codeman notify (the skill or a CLAUDE.md covers the rest by hand). It is a separate switch, off by default, so nobody gets a typed message unless they turn it on, and Claude's launch-flag hint (startupHint) works without it.

If you would rather not carry it, removing it is one commit and I am happy to do that instead. Thank you for the thorough reviews.

@Ark0N

Ark0N commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the four items from the last round and each one is in and tested: postJson takes a timeoutMs and a test pins status 0 against a server that never answers, threadFor discards a thread whose parent channel changed during creation and clears only its own pending entry, the flat-layout fallback is gone, and docs/architecture-invariants.md has a Discord bridge section. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the touched test files and the full npm test are green.

A fresh read of the whole change found a few small things:

  1. docs/architecture-invariants.md: dc22519a ran Prettier over the whole file, so 30 lines outside the new Discord section changed (emphasis markers, table padding, a \_ escape, a joined line), and line 1144 (the test/mobile/** paragraph) lost the space after one code span and before the next, so both run into the neighbouring word when rendered. It is the same Prettier markdown damage that keeps CLAUDE.md in .prettierignore, and the same kind of reformat you already undid on api-reference.md in e3ca07d5. Please restore the file from the base (git checkout f1f7eec4d6a5 -- docs/architecture-invariants.md) and add back only the Discord section.

  2. src/web/routes/discord-routes.ts:54: resolveSessionId matches the 8-character prefix against every session before findSessionOrFail checks ownership, and the 404 names the id it was given. In multi-user mode a non-admin who sends the prefix of another user's session gets Session <that session's full id> not found, while an unknown prefix echoes only the prefix (reproduced with a route test). Elsewhere, sessions the caller cannot access are never revealed (approval-routes.ts: "filtered (never 403-leaked)"). This also answers your note on resolveParentSessionId: only its canAccessOwned filter is needed here, not the owner-equality check. Passing req in and keeping the candidates where canAccessOwned(getAuthUser(req), s.owner) fixes it for an admin and an owner alike; please add a route test with CODEMAN_MULTIUSER=1 and a session owned by another user.

  3. src/discord-bridge.ts:152: sanitiseReply always appends \r, so a reply of 2 to a permission prompt or a question is typed as the key 2 (submitsAsKeys in TmuxManager.sendInput) and followed by Enter 50 ms later. The approvals inbox never sends Enter after a menu digit, because the dialog acts on the keypress itself (approval-routes.ts:31-36), and the alert's screen tail shows the numbered options, so a digit is the natural reply from a phone. The extra Enter reaches whatever is on screen 50 ms later: on an empty composer it does nothing, on a dialog that follows it confirms the highlighted option. One shape: a dep that tells the bridge whether the session has a pending permission or question item in the approvals inbox, and a single-character reply sent without \r only in that case (a bare y to an idle prompt still needs its Enter).

  4. Docs that no longer match the code:

    • docs/discord-bridge.md:111 and docs/wiki/Discord-Bridge.md:68 still say to add .codeman-discord/ to .gitignore; the folder ignores itself now.
    • docs/discord-bridge.md:120 and docs/wiki/Discord-Bridge.md:80 say the bridge "logs once"; nothing in discord-bridge.ts logs, the error shows in Settings.
    • docs/discord-bridge.md:79 points at "Settings, Discord" (the group is under Notifications), and :81 and :124 say "Test" (the button is "Send test").
    • The Claude hint reaches local tabs only (remote and Docker Claude launches are built outside the registry template, which is right since they have no codeman). Please say so in "Telling the CLIs", on the wiki page and in the "Tell Claude how to reach you" Settings row.
    • docs/security-architecture.md: "or typed once to a brand-new external CLI tab" needs "when typedHint is on".
  5. src/web/server.ts:1359: the bridge and its gateway start in setupRoutes(), before restoreMuxSessions(), so a reply that arrives during restore is answered "That tab is gone, so nothing was typed." for a tab that is coming back. Starting the bridge after finalizeRestoredState(), or answering "Codeman is starting, send it again" until restore is done, fixes it.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet.

Once that question is settled this is ready to merge. You are welcome to push the items above; whatever is still open at merge time is small enough to apply on top. Thank you again for this feature and for the care you have put into every round.

…store, hide other users' session ids, fix docs

- a lone digit reply is typed without Enter while the tab has a pending permission or question
- the gateway starts after restoreMuxSessions (hold and release), so a reply during restore is not told the tab is gone
- discord-notify resolves an id prefix only among sessions the caller may see
- restore docs/architecture-invariants.md from base and add only the Discord section
- docs: self-ignoring folder, no logging, Notifications group, Send test, local-tab-only Claude hint, typedHint wording
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all five are in on this push (the full npm test is green: 670 files, 13,616 tests).

  1. architecture-invariants.md. Restored from f1f7eec4 and only the Discord section added back (12 added lines, nothing else touched). I will not run Prettier over the docs again.
  2. Prefix reveals another user's session. resolveSessionId now takes req and only considers sessions where canAccessOwned(getAuthUser(req), s.owner), so an inaccessible session behaves like an unknown prefix. A route test with CODEMAN_MULTIUSER=1 and a session owned by another user checks the 404 does not contain the full id.
  3. Digit replies. A awaitingChoice(sessionId) dep (the same approvalInbox check as hasPendingApproval: a pending non-idle item) lets the bridge type a lone digit without \r only while a permission or question is waiting. A bare y or any other text still gets its Enter, and so does a digit when nothing is waiting. Tested all three.
  4. Docs. Fixed: the self-ignoring folder (no .gitignore advice), "logs once" (now says the last error shows in Settings), the Notifications group, Send test, the Claude hint reaching local tabs only (in "Telling the CLIs", the wiki page and the Settings row), and typedHint in security-architecture.md.
  5. Restore race. The bridge now has hold()/release(): setupRoutes() holds the gateway back and start() releases it after finalizeRestoredState(). Config and the thread map still load at once, and alerts still go out; only the inbound gateway waits. A test checks the gateway stays off while held and starts on release.

typedHint: I said earlier I would like it to stay, and that stands.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the five items from the last round and each one is in and tested: docs/architecture-invariants.md is back to the base with only the Discord section added, a session prefix resolves only among sessions the caller may see, a lone digit is typed without Enter while a permission or question is pending, the docs match the code, and the gateway waits for restoreMuxSessions() through hold()/release(). Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the touched test files and the full npm test are green.

A fresh read found only small things:

  1. The lone-digit rule is not written down yet. CLAUDE.md:134 ("a reply is one printable line plus Enter"), docs/architecture-invariants.md:1372, docs/discord-bridge.md:12, docs/wiki/Discord-Bridge.md:18, docs/api-reference.md:1500 and the comments at src/discord-bridge.ts:15 and :143 still give the old rule. One clause in each: a lone digit while a permission or question is pending is sent as the key alone, without Enter.
  2. docs/discord-bridge.md:107 and docs/wiki/Discord-Bridge.md:22 say codeman is on the PATH of every session. It is for installer and npm installs, but a git checkout run without the installer has no codeman command (README.md:1052 says so for the MCP server). Please say which installs have it.
  3. src/discord-bridge.ts:707: the reload() doc comment ended up above hold(), so reload() has none. At :648-649 the two stacked comments on sendInput can become one, and "already ending in Enter" is no longer true for a digit. At :179-181 the hint comment should say the typed message is sent only when typedHint is on.
  4. src/discord-bridge.ts:186: the hint has a stray . after --urgent, which reads as part of the command. Ending the command without it (for example --urgent (lead with the action). The user reads ...) avoids an agent copying the dot.
  5. docs/wiki/Config-Backups.md:192: "(the URL is a bearer secret)" now follows discord.json, which holds a token. Something like "webhook.json (the URL is a bearer secret), discord.json (the bot token)".
  6. CLAUDE.md:194: the Auto-named sessions list needs a comma after POST /api/sessions/:id/input.

These are small enough that I will apply them on top at merge time unless you would rather push them yourself.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet. Once that question is settled, this is ready to merge. Thank you again for this feature and for the care you have put into every round.

…deman, and tidy comments

- lone digit rule in CLAUDE.md, invariants, user docs, wiki, API reference and code comments
- say which installs have the codeman command
- reload() comment back above reload(), merge the sendInput comments, typedHint in the hint comment
- drop the stray dot after --urgent in the startup hint
- config-backups wiki: name the discord.json token, not the URL
- comma in the CLAUDE.md auto-named list
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all six are in on this push (docs and comments, plus the hint string; the full npm test is green, 670 files, 13,616 tests).

  1. The lone-digit rule is now in CLAUDE.md, docs/architecture-invariants.md, docs/discord-bridge.md, the wiki page, docs/api-reference.md and the comments at the top of discord-bridge.ts and on sanitiseReply.
  2. docs/discord-bridge.md and the wiki say codeman is there for installer and npm installs, and that a git checkout run without the installer has no codeman command.
  3. The reload() comment is back above reload(), the two sendInput comments are one (it now says a line ending in Enter, or a lone digit for a dialog), and the hint comment says the typed message is sent only when typedHint is on.
  4. The stray . is gone: --urgent (lead with the action). The user reads ....
  5. Config-Backups.md now reads "webhook.json (the URL is a bearer secret), discord.json (the bot token)".
  6. Comma added in the CLAUDE.md Auto-named list.

typedHint: I said earlier I would like it to stay, and that stands.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the six items from the last round and each one is in: the lone-digit rule is written down in CLAUDE.md, docs/architecture-invariants.md, both Discord docs, docs/api-reference.md and the comments; both docs say which installs have the codeman command; the reload(), sendInput and hint comments are fixed; the stray . is gone from the hint; Config-Backups.md names the bot token; and the comma is in. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check, the touched test files and the full npm test are green.

A fresh read found one small thing and three nits. None of them blocks.

  1. src/web/server.ts:515: a reply to a tab whose agent has exited gets the tick reaction. tmux send-keys succeeds against a dead pane (the input route corrects for it at src/web/routes/session-routes.ts:2258), and a crashed agent keeps its row with Session.paneExit set, so the person on the phone waits for an agent that is not there. Please check session.paneExit (a cached field, no tmux exec) in the sendInput dep and answer in the thread that the agent has exited and nothing was typed, with a glue test for a session whose paneExit is set.
  2. src/discord-bridge.ts:1009: each gateway message runs on its own and the per-tab gate only spaces replies by a second, so a reply with a file (typed after its downloads) can reach the agent after a text reply sent a few seconds later. A per-session promise chain around onGatewayMessage, like persistChain, keeps them in order.
  3. src/cli-notify.ts:67: postJson still hangs if the server sends headers and then stalls the body. The timeout destroys the request, but after the response has started that emits close on res without end and no error on req, so the promise never settles (a local server that writes headers and half a JSON body reproduces it). Settling once on res.on('close'), with a guard so a normal end still wins, closes it.
  4. src/discord-bridge.ts:731: entries leave discord-bridge.json only through sessionDeleted(), so a tab dropped another way (dismissing the reboot-restore offer, for example) keeps its entry for good. Pruning the map against the live session ids once after release(), as pruneAgentSessionPreambles does for the agent preambles, bounds it.

You are welcome to push these in the same branch.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet, and nothing else stands between this PR and a merge. Thank you again for this feature and for the care you have put into every round.

…ettle a stalled response, prune dead tabs

- a reply to a tab whose agent has exited is answered in the thread, with no tick
- inbound messages run on one chain per thread, so a reply with a file cannot be overtaken
- postJson settles when a response stalls after its headers
- the thread map is pruned against the live sessions once after boot
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all four are in on this push (the full npm test is green, 670 files, 13,621 tests).

  1. Tick for an exited agent. The bridge has an agentExited(sessionId) dep, wired to session.paneExit (the cached field, no tmux exec). When it is set the bridge answers in the thread "The agent in that tab has exited, so nothing was typed." and does not type or download anything. A bridge test covers the reply and the missing tick, and a glue test builds a real WebServer and checks the dep is true only for a session with paneExit.
  2. Reply order. onGatewayMessage runs each thread's messages on its own promise chain (cleared in stop()), so a reply that waits on downloads is typed before a later text reply. A test holds the first one under way and sends a second.
  3. Stalled body. postJson settles once, and res close or error without end resolves status 0. A test uses a local server that writes headers and half a JSON body.
  4. Pruning. prune(liveIds) drops thread entries for tabs that no longer exist. start() runs it once after release(), against this.sessions. It does nothing when no sessions were restored, since that is more likely a failed restore than an empty server. Tested.

typedHint: I said earlier I would like it to stay, and that stands.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the four items from the last round and each one is in and tested: a reply to a tab whose agent has exited is answered in the thread with no tick, inbound messages run on one chain per thread, postJson settles when a response stalls after its headers, and the thread map is pruned once after boot. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the touched test files and the full npm test are green.

A fresh read found one small bug and a few nits:

  1. src/web/server.ts:3851: discardPartiallyBuiltSession() now calls discordBridge.sessionDeleted(), but the reboot-restore rebuild calls it too, and it rebuilds with the old session id (src/web/routes/reboot-restore-routes.ts:173). After a host reboot the thread map still holds that id (the boot prune returns early on an empty restore), so a rebuild that fails archives the tab's thread, or deletes it with When a tab is deleted set to Delete, and drops its entry, while the session stays on offer for a retry (reboot-restore-routes.ts:287). The retry then starts a second thread. A small test reproduces it. Please give the bridge a method that clears only the per-tab maps (names, lastReply, fresh) and call that here instead, correct the comment, and add a test that seeds a thread, discards the session and expects the thread kept.
  2. src/discord-bridge.ts:825: a tab closed while its thread is still being created keeps an entry in the map, and the thread is never archived or deleted, because sessionDeleted() finds no entry yet and the creation records one afterwards. Remembering the delete and applying onSessionDelete to a thread that finishes for a deleted tab closes it.
  3. src/discord-bridge.ts:297: every write of discord.json uses the same tmp name, so two PUT /api/discord at once (two admin tabs) can leave invalid JSON, which reads back as the defaults and drops the token. webhook-notify.ts has the same pattern; adding a randomUUID() to the name, as src/web/settings-writer.ts:69 does, is enough.
  4. docs/discord-bridge.md:125: deleting discord.json does not reach the running bridge, which keeps the token it loaded until a restart. Pointing at Remove token in Settings instead, or adding "then restart Codeman", matches what the code does.
  5. src/discord-bridge.ts:722: prune() keeps only live sessions, while the agent inbox's boot prune (src/web/server.ts:4250) also keeps the records state.json still holds and the sessions on the reboot-restore offer, because a rebuild reuses the id. Passing the same keep set protects the reboot case directly instead of through the empty-restore guard.

These are small enough that I will apply them on top at merge time unless you would rather push them yourself.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet. Once it is settled, this is ready to merge. Thank you again for this feature and for the care you have put into every round.

…for a closed tab, unique tmp names, wider prune keep set
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all five are in and tested. The full npm test is green on the new tip (13,624 tests).

  1. Reboot-restore rebuild. discardPartiallyBuiltSession() now calls a new forgetTab(), which clears only names, lastReply and fresh. The comment is corrected. A glue test discards a session and expects forgetTab called and sessionDeleted not, and a bridge test seeds a thread, calls forgetTab and expects the map entry kept.
  2. Tab closed during thread creation. sessionDeleted() remembers a tab whose creation is in flight, and the creation then applies onSessionDelete to the new thread and records no entry. Tested with a held createThread.
  3. Shared tmp name. writePrivate now uses randomUUID() in the tmp name. I left webhook-notify.ts alone as it is outside this PR.
  4. Docs. The revoke step now says to use Remove token in Settings, and that deleting discord.json alone does not reach a running bridge.
  5. Prune keep set. prune() now gets live sessions, the records in state.json and the reboot-restore offer, the same set as the agent inbox prune.

On typedHint: still your call, and it is one commit to drop.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the five items from the last round and each one is in and tested: a failed reboot-restore rebuild now calls forgetTab() and keeps the thread, a tab closed while its thread is being created has that thread closed by the creation, discord.json writes use a unique tmp name, the revoke step points at Remove token, and the boot prune keeps the same set as the agent inbox prune. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the touched test files and the full npm test are green.

A fresh read found one small bug and two nits:

  1. src/discord-bridge.ts:790: fail() reads only an HTTP status or a TimeoutError, so the three reasons threadFor builds as plain errors (:826 "Discord did not return a thread", :828 "The parent channel changed", :831 "The tab was closed") all come back as Network error. codeman notify then prints Could not post to Discord: Network error and Settings shows the same, which points the person at their network. A short probe confirmed it for the missing-id and closed-tab cases. Falling through to err.message for any other Error (or having threadFor build the DiscordResult itself) fixes it, and the test at test/discord-bridge.test.ts:582 can then assert the text, not only ok: false.
  2. src/discord-bridge.ts:765: every reload() stops the gateway and identifies again, and Send test saves first, so each Save or Send test click opens a new gateway session and drops the resumable one. Restarting the gateway only when enabled or the token changed avoids that. Nit.
  3. src/web/routes/discord-routes.ts:188: the rate-limit token is spent before fileProblem runs, so a request refused with 400 for a bad --file still counts toward the 6 a minute. Nit.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet. The items above are small enough to apply on top at merge time, and you are welcome to push them yourself if you prefer. Thank you again for this feature and for the care you have put into every round.

…cross a save, refund the rate limit for a refused file
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all three are in and tested. The full npm test is green on the new tip (13,626 tests).

  1. Network error for plain failures. The three reasons threadFor builds (no thread id, parent channel changed, tab closed) are now a small BridgeProblem error, and fail() shows its message as it is. Anything else still reads as a network error or timeout, so no stray error text reaches Settings or codeman notify. The tests now assert the text for the missing-id and closed-tab cases.
  2. Gateway restarted on every save. reload() now keeps the running gateway when neither the on/off switch nor the token changed, so Save and Send test no longer drop the resumable session. A test checks it is kept on a plain reload and replaced on a new token.
  3. Rate-limit token spent on a refused file. A request refused with 400 for a bad --file gives its token back. I kept the spend-first order so the file probe stays behind the limit check. A route test sends eight bad files and then a good message, which still gets 200.

On typedHint: still your call, and it is one commit to drop.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the three items from the last round and each one is in and tested: the reasons threadFor builds now reach Settings and codeman notify as they are, a Save that changes neither the switch nor the token keeps the gateway session, and a --file refused with 400 gives its rate-limit token back. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check, the touched test files and the full npm test are green.

The gateway change introduced one regression:

  1. src/discord-bridge.ts:773: reload() returns early when the bridge is on and the token equals gatewayToken, without looking at the gateway's state. After a fatal close (4004, 4010 to 4014) the gateway sets state = 'error' and stops reconnecting (:517-525), but the bridge keeps the object and gatewayToken, so a Save with the same token keeps the dead gateway. The common case: Message Content Intent is off, Settings says so, the user turns it on in the Developer Portal (as docs/wiki/Discord-Bridge.md:89 tells them) and presses Save, and replies are still never read; only switching the bridge off and on, Remove token and re-paste, or a restart recovers. A scratch test (fake socket, drop(4014), the same config written again, reload()) opens no new socket on this commit and a new one on a698729f. Please add the state to the condition, for example if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken) return;, and a test that drops the socket with 4014, reloads the same config and expects a new socket and a state other than error.

This is small enough that I can apply it on top at merge time if you would rather not push it yourself.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet. Once that question is settled and the fix above is in, this is ready to merge. Thank you again for this feature and for the care you have put into every round.

@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, that was my regression and it is fixed. The full npm test is green on the new tip (13,627 tests).

reload() now keeps the running gateway only when it is not in the error state, so a Save with the same token replaces one that gave up after a fatal close, as in the Message Content Intent case. A test drops the socket with 4014, reloads the same config and expects a new socket and a state other than error.

On typedHint: still your call, and it is one commit to drop.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the quick fix in 9f18b788, and for the care you have put into every round. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

The gateway fix is in and tested: reload() now replaces a gateway in the error state, and the new test drops the socket with 4014, saves the same config and gets a new socket. Typecheck, lint, format, frontend syntax, the browser-test exclusion check and the full npm test (670 files, 13,627 tests) are green here.

A fresh read found two small bugs and one doc nit:

  1. src/discord-bridge.ts:537: handle() does not check that a frame came from the current socket. stop() clears the timers and drops this.sock, but ws keeps emitting message while a socket is closing, so a HELLO that arrives just after stop() (a Save that changes the token or turns the bridge off right after it connected, a config restore, a server stop soon after boot) sets a heartbeat interval on the stopped gateway. The socket's later close is ignored because it is no longer this.sock, so that interval runs for the life of the process. A test with a socket that stays open after close() reproduces it: after start(), stop() and a HELLO, one timer is left and an IDENTIFY is queued. Please start handle() with if (this.stopped || sock !== this.sock) return; and add that test (no timer left, nothing sent).

  2. src/discord-bridge.ts:949: with typedHint on, sessionIdle() types the hint and Enter on the first idle without the awaitingChoice check the reply path uses. A new codex tab in a folder codex has not trusted opens on its workspace-trust dialog (› 1. Trust and continue, see src/config/cli-registry/stock.ts:734). The screen read files that as a blocked permission item and reports the pane as not working (src/session.ts:3548), and the trust-dialog hold matches only Claude's wording, so the tab is concluded idle with the dialog up. The hint's Enter then picks the highlighted option for the user, and the tab is no longer fresh, so the agent never gets the note. Please check awaitingChoice(sessionId) before taking the session out of fresh, so the hint waits for the next idle, with a test that holds it while a choice is pending and sends it once the choice is gone.

  3. docs/wiki/Settings-Reference.md:216: the Notifications section names the webhook but not the Discord group. One clause and a link to Discord Bridge covers it.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet, and item 2 applies only if it stays.

These are small enough to apply on top at merge time, and you are welcome to push them yourself. Once the typedHint question is settled and the unit-test CI job is green, this is ready to merge. Thank you again for this feature.

…hile a choice is pending, name the Discord group in the settings reference
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all three are in and tested. The full npm test is green on the new tip (13,629 tests).

  1. Frames after stop(). handle() now starts with if (this.stopped || sock !== this.sock) return;. A gateway test with a socket that keeps delivering after stop() sends a HELLO and expects no timer left and nothing sent.
  2. Typed hint over a dialog. sessionIdle() now checks awaitingChoice(sessionId) before taking the tab out of fresh, so the hint waits for the next idle. A test holds it while a choice is pending, sends it once the choice is gone and not a second time.
  3. Settings reference. The Notifications section now names the Discord group and links to Discord Bridge.

On typedHint: item 2 applies only if it stays, and it is still your call. Dropping it is one commit.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the three items from the last round and each one is in and tested: handle() ignores a frame that arrives after stop() or from a replaced socket, the typed hint waits while a permission or question is pending and goes out at the next idle, and the Settings reference names the Discord group with a link to the Discord Bridge page. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check and the touched test files are green here.

A fresh read found two nits. Neither blocks:

  1. src/discord-bridge.ts:843: when the parent channel changes while a thread is being created, the result is dropped from the map, but the thread Discord just made in the old channel stays open there. Calling closeThread(t.id) before returning, as the closed-tab branch at :844-846 does, tidies it. In the same spot, reload() clears pending at :768 but not closedWhilePending, so the finally at :855-857 no longer clears that tab's entry; clearing both sets together keeps them in step.
  2. src/discord-bridge.ts:952-956: sessionIdle() holds on a pending choice before it looks at the settings, so with the bridge or typedHint off, a tab whose first idle shows a dialog stays in fresh. If both are turned on later, that tab gets the hint at its next idle although it is no longer new. Checking the settings first (and dropping the tab from fresh when they are off), then the awaitingChoice hold, avoids it. This one applies only if typedHint stays.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. You have said you would like it to stay; the decision is not made yet, and it is the one thing left before this merges. The two nits are small enough to apply on top at merge time, and you are welcome to push them yourself if you prefer. Thank you again for this feature and for the care you have put into every round.

…ding sets in step, settle the hint when the settings are off
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, both nits are in and tested. The full npm test is green on the new tip (13,630 tests).

  1. Old-channel thread. When the parent channel changes during a creation, the new thread is now closed through closeThread() (so it follows onSessionDelete) before the failure is returned. reload() clears closedWhilePending together with pending. The existing race test now also expects the archive call on the old thread.
  2. Hint settings before the hold. sessionIdle() now checks the settings first and drops the tab from fresh when the bridge, startupHint or typedHint is off, then applies the awaitingChoice hold. A test takes a first idle with typedHint off, turns it on, and expects nothing typed at the next idle.

On typedHint: still your call, and item 2 applies only if it stays. Dropping it is one commit.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last push and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked both nits from the last round and each one is in and tested (d6c7d30b): a thread created in the old channel while the parent channel changed is now closed through closeThread(), reload() clears closedWhilePending together with pending, and sessionIdle() checks the settings before the dialog hold, so a tab that passed its first idle with the hint off is not hinted later. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check, the touched test files and the full npm test are green here.

A fresh read of the whole change found four small things. None of them blocks, and they are small enough to apply on top at merge time; you are welcome to push them yourself if you prefer.

  1. src/discord-bridge.ts:48-117 and src/web/routes/discord-routes.ts:42: DiscordConfig, DiscordResult, DiscordGatewayState, DiscordStatus and the three option lists live in the IO module and the route file, so src/web/schemas.ts:23 now imports discord-bridge.ts (and with it ws) for three string lists. CLAUDE.md (Adding Features, API endpoint) puts API types in a src/types/ domain file, and the webhook keeps its equivalents in src/types/push.ts. Moving them there and importing them from the bridge, the route and schemas.ts matches it.
  2. src/web/server.ts:3262: the thread-map prune runs after a failed restore too, while finalizeRestoredState() skips the agent inbox prune and the tab layout reconciliation when restoreMuxSessions() returns false (CLAUDE.md, Agent inbox: boot prune only after a successful restore). The keep set makes the effect small, but keeping release() unconditional and pruning only when restored is true puts the three boot prunes on one rule.
  3. docs/wiki/Discord-Bridge.md:48 and :58 call the setting "Also tell other CLIs, as a message"; the Settings row says "Also tell other CLIs, as a first message".
  4. docs/discord-bridge.md:10 says the bridge posts when a session "stops". A turn end is the info event, which is posted only when Which events is Everything; with the default it is skipped.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. You have said you would like it to stay; the decision is not made yet, and it is the one thing left before this merges. Thank you again for this feature and for the care you have put into every round.

…stale thread cleanup, types moved to src/types
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all four are in and tested. The full npm test is green on the new tip (13,661 tests).

  1. Types. DiscordConfig, DiscordResult, DiscordGatewayState, the new DiscordStatus and the three option lists now live in src/types/discord.ts (exported from the types barrel). The bridge, the route and schemas.ts import them from there, so schemas.ts no longer pulls in discord-bridge.ts and ws.
  2. Boot prune. release() is still unconditional, and the thread-map prune now runs only when restoreMuxSessions() succeeded, like the agent inbox prune and the tab layout reconciliation.
  3. Wiki wording. Both mentions now read "Also tell other CLIs, as a first message".
  4. Docs. docs/discord-bridge.md no longer says the bridge posts when a session "stops": a finished turn is an info event and posts only when Which events is Everything.

Two additions in the same push, on a request from the person running this:

  • Whole last message. A finished turn now posts the CLI's whole last message as markdown instead of the last lines of the screen, which started mid-sentence and carried the prompt row and status lines. It reads through the response viewer's reader: GET /api/sessions/:id/last-response is now a thin route over a lastResponseFor(session, context) function in session-routes.ts (same body, so the route is unchanged), which the server takes from the route context and passes to the bridge. It reads the CLI's own transcript where there is one and otherwise the pane split into turns. A long message is split over several posts, code fences kept, and the same text is never posted twice. A dialog and a shell keep the screen tail, with the prompt row and everything under it cut. A new test drives the real route for Claude, Codex, OpenCode, Gemini, Antigravity, Pi and Copilot with a message of three numbered questions, and checks all three come back whole and in order, including after the split. DeepSeek, OMP and Grok use the same wiring but are not covered with that fixture.
  • Stale thread cleanup (staleThreads: keep, archive or delete; default keep). After boot and then daily, it closes threads the bot made that no tab maps to. It skips a thread under an hour old, one the bot does not own, any run with an empty map, and closes at most 25 per run. Tested, with the setting in Settings and the docs.

On typedHint: still your call, and it is one commit to drop.

@opticon454

Copy link
Copy Markdown
Contributor Author

One more small follow-up on the whole-message post: Discord does not draw Markdown tables, so a table in the CLI's last message showed as raw pipes. tablesToCodeBlocks() now turns each table outside a code fence into a code block with the columns padded to their widest cell (right-aligned where the separator row says ---:), and drops bold and code marks inside cells. A table inside a fence, and prose that merely contains pipes, are left alone. Tests cover the conversion, escaped pipes, short rows and the post itself. The full npm test is green (13,665 tests).

On typedHint: still your call, and it is one commit to drop.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the last two pushes and for the care you have put into every round. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways. The two newest commits post a finished turn as the CLI's whole last message (Markdown tables as aligned code blocks) and add the stale-thread sweep.

I read the whole change again with those commits in. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check, the touched test files and the full npm test (671 files, 13,665 tests) are green.

Must fix

  1. src/discord-bridge.ts:234: the separator test in tablesToCodeBlocks is quadratic in a run of whitespace. The \s* after -+:? and the \s* before $ can split one run of spaces every way before the match fails. It runs synchronously on the agent's own last message after every Claude turn once the bridge is on (hook:idle_prompt is a warning, so it passes the default scope), and that message can be steered by a file or page the agent reads. Measured through the exported function: a row of |---, 30,000 spaces and one more character takes 0.76 s, 60,000 spaces take 3.3 s, and five such rows in a 150 KB message take 3.9 s, all of it a stall of the whole server. trim() alone does not fix it (a run inside the row still took 1.5 s at 40,000). Stripping whitespace first and testing a pattern with no whitespace in it is linear (0.2 ms at 40,000) and gives the same answers on ordinary separator and non-separator rows:

    const isSeparator = (row: string) => {
      const r = row.replace(/\s+/g, '');
      return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r);
    };

    Please add a test in test/discord-bridge.test.ts that converts a message with a 100,000-space separator row in well under a second, so the shape cannot come back.

Smaller things

  1. src/discord-bridge.ts:1079: lastFull is set before the post is attempted. With Which events on Everything, a stop whose post fails (I used a fetch that answers 500) is followed by an idle alert that posts only its header, so the answer never reaches Discord. It is also keyed on the text alone, so a later turn that ends with the same words ("Done.") posts only its header. Please set it only after the first post succeeds, and key it on the text plus the transcript timestamp that lastResponseFor already returns.
  2. docs/discord-bridge.md:11, docs/wiki/Discord-Bridge.md:51, the Discord paragraph in CLAUDE.md and the PR description say every CLI posts its finished turn, with the pane split into turns for OpenCode, Gemini, Antigravity, Pi, Grok and Copilot. Turn-end alerts only come from /api/hook-event: Claude sends stop and idle_prompt, DeepSeek Harness sends only stop (an info event, so only with Everything), and the other eight CLIs declare hooks: 'none' and never send a turn end, so their last message reaches Discord only with a Session Error, Respawn Blocked or crash-loop alert. test/routes/discord-last-message-per-cli.test.ts proves the reader, not the trigger. Please say which CLIs post turn ends, and that the others post their dialogs (codex reads them off the screen), errors and codeman notify.
  3. docs/security-architecture.md:585 still says each alert sends the rendered screen, and every turn end only with Everything. Since 093f2426 a finished turn sends the CLI's whole last message from its transcript (up to 8 posts of 1,900 characters), and because idle_prompt is a warning, the default scope already posts every Claude turn left unanswered for a minute. Please update that sentence, and add a bullet for the whole-message path (the lastResponseFor reader, the 4 s bound, the screen fallback, the repeat check) to the Discord section of docs/architecture-invariants.md.
  4. docs/wiki/Discord-Bridge.md:16: "each with what is on the tab's screen right now" now contradicts line 51.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet.

Item 1 needs to be in before this merges. Items 2 to 5 are welcome in the same push; whatever is still open at merge time is small enough to apply on top. Thank you again for this feature.

…essage only after it posts, say which CLIs raise a turn end
@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all five are in and tested. The full npm test is green on the new tip (13,668 tests). Item 1 was my bug, and a real one.

  1. Quadratic separator test. isSeparator now strips whitespace first and tests a pattern with none in it, exactly as you suggested, so it is linear. A new test converts a message whose separator row is |--- plus 100,000 spaces and one more character, and one with a real separator row padded by 50,000 spaces, and expects each to finish in under half a second (they take a few milliseconds).
  2. lastFull. It is now recorded only after the first post succeeds, and keyed on the text plus the turn it came from: the transcript timestamp that lastResponseFor returns, and for a pane CLI (which has none) the session's last submit. fullText now returns { text, version }. Two tests: a stop whose post gets a 500 is followed by an idle alert that does post the message, and a later turn ending on the same words posts while the same turn twice does not.
  3. Which CLIs post a turn end. You are right that the per-CLI test proves the reader and not the trigger. docs/discord-bridge.md, the wiki page, CLAUDE.md and the PR description now say that Claude Code sends stop and idle_prompt, DeepSeek Harness only stop, and that OpenCode, Gemini, Antigravity, Pi, Grok and Copilot reach Discord through errors, a blocked respawn, a crash loop or codeman notify, with Codex's dialogs read off its screen.
  4. security-architecture.md and the invariants. The "What leaves the machine" sentence now says a permission or question alert sends the screen, and a finished turn or an error sends the CLI's whole last message (up to 8 posts of 1,900 characters), including that idle_prompt posts every unanswered Claude turn under the default scope. docs/architecture-invariants.md has a new Discord bullet for the whole-message path: the lastResponseFor reader, the 4 s bound, the screen fallback, the linear separator test and the repeat check.
  5. Wiki line 16. The Alerts bullet now says a question or permission prompt shows the screen, and a finished turn or an error carries the CLI's whole last message.

On typedHint: still your call, and it is one commit to drop.

@Ark0N

Ark0N commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the quick turnaround on the last round, and for testing each change. To recap for anyone reading along: this PR adds an opt-in, two-way Discord bridge, with alerts in a thread per tab, allowlisted replies typed into that tab, codeman notify for agents that need a person, and files both ways.

I checked the five items from the last round against 02164191 and each one is in: isSeparator strips whitespace first and has a 100,000-space test, lastFull is recorded only after the first post and is keyed on the text plus the turn, the docs say which CLIs raise a turn end, security-architecture.md and the invariants describe the whole-message path, and the wiki Alerts bullet matches. Typecheck, lint, format, frontend syntax, the browser-test exclusion check, the CLI catalogue check and the touched test files are green.

Must fix

  1. src/discord-bridge.ts:254-260: the table conversion is still quadratic, now in the table's shape rather than the regex. cols is the widest row, and the width pass and fmt both run once per column for every row, while every following line containing | joins the table. So one wide header followed by many one-pipe lines is enough. Measured through the exported function: 3000 columns by 3000 rows (18 KB) took 1.7 s, 8000 by 8000 (47 KB) took 12.5 s. Through DiscordBridge.notify() with an idle-prompt alert whose last message is a 35 KB table of that shape, the event loop stalled for 7.4 s. It is the same situation as last round: synchronous work on the agent's own last message after every turn left waiting, and that message can be steered by what the agent reads. Please bound the table before formatting it, for example by leaving the original lines as they are when a table has more than about 24 columns or 200 rows (raw pipes are what Discord showed before this commit), and replace the Math.max(...rows.map(...)) spreads with a loop, since they throw RangeError at about 125,000 rows. Please add a test beside the 100,000-space one that converts a 3000-column header followed by 3000 one-pipe rows in well under a second.

Smaller things

  1. src/discord-bridge.ts:1117: with lastFull set after the post, two alerts for the same tab that overlap (a Ralph completion and an idle prompt, or an error and a stop under Everything) both post the whole message. Setting the entry before the post and restoring the previous value if the post fails keeps both the retry and the no-repeat behaviour.
  2. docs/discord-bridge.md:10 and docs/wiki/Discord-Bridge.md:56-58: OMP sends no turn end either (it inherits hooks: 'none'), but it is missing from the list of CLIs that send none and appears only in the transcript-reader list. Please add it there, and Codex for its turn ends.
  3. docs/discord-bridge.md:96 and the Settings row at src/web/public/index.html:3282: a deleted tab's thread leaves the map, so the stale sweep also archives or deletes threads that onSessionDelete kept or archived. One sentence saying so avoids a surprise.
  4. src/discord-bridge.ts:1118: parts 2 to 8 go through say(), which swallows errors, so a message whose later parts fail is still reported as posted. A short comment that this is deliberate is enough.
  5. src/discord-bridge.ts:253: a line containing a pipe directly under a table (no blank line) is pulled into the code block. Requiring rows to start with | when the header does would keep prose out. Cosmetic.

Item 1 needs to be in before this merges. Items 2 to 6 are welcome in the same push; whatever is still open at merge time is small enough to apply on top.

Still open on my side: whether the typed first-message hint (typedHint, off by default) stays in this PR. That is not decided yet. Thank you again for this feature and for the care you have put into every round.

@opticon454

Copy link
Copy Markdown
Contributor Author

Thank you, all six are in and tested. The full npm test is green on the new tip (13,671 tests). Item 1 was mine again, and your measurements were right.

  1. Table shape. A table is now measured before it is formatted: more than 24 columns or 200 rows, and its original lines are left exactly as written (raw pipes, as before this feature). The Math.max(...) spreads are loops. I also hit the same limit in my own fix, out.push(...lines.slice(i, j)), which overflowed the stack at 200,000 rows, so that is a loop too. A new test beside the 100,000-space one runs a 3000-column header over 3000 one-pipe rows and a 200,000-row table, each in well under a second (a few milliseconds here), plus a 300-row table that must come back unchanged.
  2. Overlapping alerts. lastFull is now claimed just before the first post and the previous value restored if the post fails, so both the retry and the no-repeat behaviour hold. A test holds the first post open, fires a second alert for the same tab, and expects the message once.
  3. OMP and Codex. Both now sit in the list of CLIs that send no turn end, in docs/discord-bridge.md and the wiki; Codex's dialogs are still noted as read off its screen. The PR description says the same.
  4. Stale sweep and kept threads. docs/discord-bridge.md, the wiki and the Settings row now say the sweep also closes threads that When a tab is deleted kept or archived.
  5. Best-effort parts. A comment on the loop says parts 2 to 8 are best effort on purpose, and that the first post, which carries the title, is what is reported.
  6. Prose under a table. When the header starts with |, a row must too, so a sentence with a pipe under a table stays prose. A test pins it.

On typedHint: still your call, and it is one commit to drop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants