Repository navigation
feat(discord): two-way Discord bridge with a thread per tab - #741
opticon454 wants to merge 24 commits into
Conversation
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.
|
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 A few things need fixing before this can merge. Must fix
Should fix
Small things
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.
…p keep their threads
…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.
|
Thank you for the careful review. All of it is addressed in the commits since Must fix
Should fix
Small things. A reply sent within a second of the last one now gets a ⏳ reaction instead of vanishing, and Your two open questions. I made the typed first-message hint its own setting, |
|
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 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
Small things
Still open on my side Whether the typed first-message hint ( 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
|
Thank you, all four and the small things are in. The full Should fix
Small things
On |
|
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, I checked every item from the last round against the code, and each one is in and tested: Must fix
Should fix
Small thing
Still open on my side Whether the typed first-message hint ( 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
|
Thank you, all five are in. The full Must fix
Should fix
Small thing
|
|
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, I checked every item from the last round against the code, and each one is in and tested: 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:
Still open on my side: whether the typed first-message hint ( 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
|
Thank you, I took all four on this push (the full
|
|
On 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. |
|
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, I checked the four items from the last round and each one is in and tested: A fresh read of the whole change found a few small things:
Still open on my side: whether the typed first-message hint ( 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
|
Thank you, all five are in on this push (the full
|
|
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, I checked the five items from the last round and each one is in and tested: A fresh read found only small things:
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 ( |
…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
|
Thank you, all six are in on this push (docs and comments, plus the hint string; the full
|
|
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, I checked the six items from the last round and each one is in: the lone-digit rule is written down in A fresh read found one small thing and three nits. None of them blocks.
You are welcome to push these in the same branch. Still open on my side: whether the typed first-message hint ( |
…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
|
Thank you, all four are in on this push (the full
|
|
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, 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, A fresh read found one small bug and a few nits:
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 ( |
…for a closed tab, unique tmp names, wider prune keep set
|
Thank you, all five are in and tested. The full
On |
|
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, I checked the five items from the last round and each one is in and tested: a failed reboot-restore rebuild now calls A fresh read found one small bug and two nits:
Still open on my side: whether the typed first-message hint ( |
…cross a save, refund the rate limit for a refused file
|
Thank you, all three are in and tested. The full
On |
|
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, I checked the three items from the last round and each one is in and tested: the reasons The gateway change introduced one regression:
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 ( |
|
Thank you, that was my regression and it is fixed. The full
On |
|
Thank you for the quick fix in The gateway fix is in and tested: A fresh read found two small bugs and one doc nit:
Still open on my side: whether the typed first-message hint ( These are small enough to apply on top at merge time, and you are welcome to push them yourself. Once the |
…hile a choice is pending, name the Discord group in the settings reference
|
Thank you, all three are in and tested. The full
On |
|
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, I checked the three items from the last round and each one is in and tested: A fresh read found two nits. Neither blocks:
Still open on my side: whether the typed first-message hint ( |
…ding sets in step, settle the hint when the settings are off
|
Thank you, both nits are in and tested. The full
On |
|
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, I checked both nits from the last round and each one is in and tested ( 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.
Still open on my side: whether the typed first-message hint ( |
…stale thread cleanup, types moved to src/types
|
Thank you, all four are in and tested. The full
Two additions in the same push, on a request from the person running this:
On |
|
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. On |
|
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, 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 Must fix
Smaller things
Still open on my side: whether the typed first-message hint ( 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
|
Thank you, all five are in and tested. The full
On |
|
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, I checked the five items from the last round against Must fix
Smaller things
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 ( |
|
Thank you, all six are in and tested. The full
On |
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.
What it does
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.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(settingstartupHint, on). Other CLIs are told only if you turn on the separatetypedHintsetting (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.<tab folder>/.codeman-discord/and its path is given to the agent.--fileuploads 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:❯ 1. Yesselected row is never cut.GET /api/sessions/:id/last-responsemoved into alastResponseFor(session, context)function insession-routes.ts; the route is a thin wrapper over it and behaves exactly as before. The route registers the function on its context andWebServerhands it to the bridge. Which CLIs qualify is registry data (capabilities.transcript,isExternalCliTranscriptMode), never a CLI id./api/hook-event, and only some CLIs send it: Claude Code sendsstop(aninfoevent, posted only with Which events on Everything) andidle_prompt(a warning, so it posts by default once a turn has waited a minute); DeepSeek Harness sends onlystop. Codex, OMP and the other CLIs declarehooks: 'none'(or send none), so their last message reaches Discord with an error, a blocked respawn, a crash-loop alert orcodeman notify; Codex's dialogs are read off its screen. The per-CLI test proves the reader, not the trigger.---:, 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.promptGlyphfrom the registry. A dialog keeps its❯ 1.option rows.codeman notify("Discord did not return a thread", "The tab was closed") instead of a generic "Network error".How
src/discord-bridge.tsis split likewebhook-notify.ts: pure helpers, a 0600 store, then IO (DiscordApiREST with an injected fetch,DiscordGatewayinbound with an injected socket,DiscordBridgerules). No new dependency (wswas already one).WebServer.sendPushNotificationsnext to the webhook, so it works with zero push subscriptions, and tab events hang offWebServer.broadcast.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.tsis thecodeman notifycommand, testable likecli-agent.ts.discord-notifyalso accepts the 8-character id a Docker case exports.webhook-routes.ts:GET/PUT /api/discord,POST /api/discord/test, admin-only in multi-user mode.Safety
~/.codeman/discord.json(0600, tmp+rename), never returned, logged or placed in an error, and is on the config-backup redaction list.allowedUserIds, the guild matches, and the channel is a thread the bridge created. Everything else is dropped unread. Bots are ignored.allowed_mentionslimited to the allowlisted ids, so agent output cannot ping a server.[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 postdiscord.jsonor a key. Remote sessions send and receive no files.docs/discord-bridge.mdis a step-by-step least-privilege setup;docs/wiki/Discord-Bridge.mdis the user-facing page.Verification
npm teston this branch: 671 files passed, 1 skipped, 0 failed (13,671 tests). Typecheck, lint andformat:checkclean.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(theWebServerwiring, with no push subscription)test/discord-startup-hint.test.ts(the launch argument is present only while enabled) andtest/routes/discord-last-message-per-cli.test.ts, which drives the reallast-responseroute 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.codeman notifywith a ping, a file received into the tab folder, and a markdown file sent with--filethat 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
CHANGELOG.mdchange, perCONTRIBUTING.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 andcodeman notifyfor agents that need you."Commits