diff --git a/.changeset/discord-bridge.md b/.changeset/discord-bridge.md index 484108c6..27f46a64 100644 --- a/.changeset/discord-bridge.md +++ b/.changeset/discord-bridge.md @@ -2,4 +2,9 @@ 'aicodeman': minor --- -**A two-way Discord bridge: one thread per tab, with replies typed back in.** Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with the tail of what the CLI printed. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so a blocked CLI can be answered from a phone away from the network. A CLI can also ask for the person deliberately with `POST /api/sessions/:id/discord-notify` (a physical step, a blocker, a timed-out watch). Threads are created on the first alert or for every new tab, and are kept, archived or deleted with the tab; pings are off, critical only or every post. The bot token is stored in `discord.json` (0600) and never returned. Setup with least-privilege bot permissions is in `docs/discord-bridge.md`. +**A two-way Discord bridge: answer a blocked agent from your phone.** Opt-in. Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with what is on the tab's screen. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so you can answer from anywhere, with no inbound exposure (it only makes outbound connections). + +- **Agents can ask for you.** `codeman notify "" [--urgent] [--file ]` posts to the tab's thread (`POST /api/sessions/:id/discord-notify`), for a physical step, a blocker or a wait that timed out. Claude is told about it in its system prompt at launch; other CLIs are sent one short message the first time a brand-new tab goes idle (a setting, on while the bridge is on; never to shell tabs). +- **Files both ways.** A file attached in a thread is saved under `/.codeman-discord/` and its path given to the agent; `--file` uploads a file of up to 8 MB from inside the tab's folder. +- **Configurable.** 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. +- **Safe by default.** The bot token is stored in `discord.json` (0600) and never returned; only allowlisted user IDs can reply; least-privilege bot setup is in `docs/discord-bridge.md`. diff --git a/CLAUDE.md b/CLAUDE.md index f779b90f..51b0d2fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session. The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab or a resumed one). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` + **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/README.md b/README.md index 3546ab39..cfe3fa03 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI +- **Discord bridge** — opt-in, two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md) - **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md) - **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart @@ -975,6 +976,7 @@ codeman session list # list sessions codeman session logs # tail output codeman task add "fix the failing test" # (t) queue a task codeman attach # show an attachment card for a local file +codeman notify "" [--urgent] [--file ] # post to this tab's Discord thread (see the Discord bridge) codeman tui --list # numbered session list (plain text when piped) codeman tui 3 # attach to session 3 of that list ``` diff --git a/docs/security-architecture.md b/docs/security-architecture.md index c7b0476c..153efeb6 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder, so it cannot post `discord.json` or a key, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 8ca07216..a848e862 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o | `clis.json` | CLI enable/disable and custom CLI entries. | | `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** | | `webhook.json` | Webhook notification target. | +| `discord.json` | Discord bridge settings. **Holds a bot token.** | | `intents.json` | Read My Mind per-case profiles. | | `linked-cases.json` | Registered cases. | | `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | @@ -188,7 +189,7 @@ that are the same are listed on one line. A file that is not JSON shows only tha a file with more than 200 changes is cut short, so use the files themselves for a full diff. The self-rewritten fields listed under "When a backup is made" are not counted as changes. -**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the +**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json`, `discord.json` (the URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, credential or key is shown as "changed" without its value. URL values lose any `user:password@`, query and fragment. @@ -196,7 +197,7 @@ query and fragment. ## Security - Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, - `push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default + `push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default location exposes nothing the data directory does not already. - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md new file mode 100644 index 00000000..1f99124b --- /dev/null +++ b/docs/wiki/Discord-Bridge.md @@ -0,0 +1,92 @@ +# Discord Bridge + +Answer a blocked agent from your phone, from anywhere. 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, so nothing is exposed to the internet +and it works when you are away from your network. + +**Opt-in, off by default. One server, one channel, threads per tab.** + +> **Treat the Discord account like a login to the machine.** A reply from an allowed account is +> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password. + +## What it does + +- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop, + each with what is on the tab's screen right now, so you can see what is being asked. +- **One thread per tab** in a parent channel, so a reply always reaches exactly one session. +- **Replies** from an allowed user are typed into that tab as one line plus Enter. +- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can + send a file back. +- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent` + posts to the tab's thread. Every session has the command; see [below](#telling-the-agents). + +## Set it up + +The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token +handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md). +In short: + +1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs. +2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and + invite it with the `bot` scope and only the permissions the guide lists (no Administrator). +3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**, + then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again. + +The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner +only), never in the shared settings, and never shown again once saved. + +## Options + +| Option | Choices | Default | +| ------------------------------ | ---------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell each CLI how to reach you | On or off | On | + +Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a +permission prompt, a question, an error, or a post an agent marked urgent. + +## Telling the agents + +An agent only posts deliberately if it knows it can. With **Tell each CLI how to reach you** on: + +- **Claude** is launched with the instruction as part of its system prompt, so nothing shows in the chat. +- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab goes idle. + Restored tabs, resumed tabs and shell tabs are never sent it. + +Tabs that were already open when you turned the bridge on are not told. The alerts still reach +Discord for them; only the agent-initiated `codeman notify` habit is missing. + +## Files + +- **You to the agent:** attach a file to a reply. It is saved as + `/.codeman-discord/-` and the line the agent sees ends with + `[attached: ]`. At most 5 files of 8 MB each. Add `.codeman-discord/` to your `.gitignore`. +- **Agent to you:** `codeman notify "message" --file ` uploads a file of at most 8 MB that lies + inside the tab's folder. +- Remote (ssh) tabs have no local folder, so they neither send nor receive files. +- A file you send is untrusted content to the agent that opens it. + +## Safety + +- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on. + Everything else is dropped. +- A reply is cut to one printable line (1000 characters) and limited to one per second per tab. +- Posts can ping only the allowlisted users, so agent output cannot ping the whole server. +- A Discord outage or a bad token never affects a session; it logs once and backs off. + +## Troubleshooting + +| Symptom | Check | +| ------------------------------------------------ | -------------------------------------------------------------- | +| Send test says 401 | The token is wrong or was reset. Paste the current one. | +| Send test says the bot cannot see it | Check the bot's role and the channel ID. | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. | +| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. | +| A thread is not deleted | The bot lacks Manage Threads on the channel. | + +See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear +from an agent, and [Security](Security) for how secrets are handled. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 3a0cd566..0efad3c4 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000 | ------------------------------------------------------------- | --------------------------------------------------- | | [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? | | [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? | +| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? | | [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? | | [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? | | [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? | diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index fcdd7f93..50dad790 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -13,6 +13,7 @@ opening the session. | Desktop notification | Another window on the same machine | Opt-in | | Push notification | Anywhere, even with no tab open | Opt-in | | Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in | +| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in | | Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in | | Phone overview | Phone home screen, NEEDS YOU section | On | | Away Digest | Afterwards, as a summary | Opt-in | @@ -66,6 +67,11 @@ Setup: Once subscribed, a blocking prompt reaches your phone even from a locked screen. +## Discord bridge: answer from your phone + +A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a +thread per tab, your reply typed into that tab, files in both directions. + ## Webhooks: ntfy, Slack, Discord **Opt-in, off by default. One channel for the whole server.** diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 0b85b925..36a04909 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -25,6 +25,7 @@ - [Unattended Runs](Keeping-Agents-Running) - [Notifications & Approvals](Notifications-And-Approvals) +- [Discord Bridge](Discord-Bridge) - [Cron Jobs](Cron-Jobs) - [Autonomous Loops](Autonomous-Loops) - [Watching Agents Work](Watching-Agents-Work) diff --git a/src/cli-notify.ts b/src/cli-notify.ts new file mode 100644 index 00000000..957e0bc1 --- /dev/null +++ b/src/cli-notify.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify). + * + * A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out + * wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every + * session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to + * print and the exit code instead of exiting, so a test can drive it. + * + * @module cli-notify + */ + +import http from 'node:http'; +import https from 'node:https'; +import { isAbsolute, join } from 'node:path'; +import { readCodemanCredentials } from './codeman-credentials.js'; + +export interface NotifyOptions { + message: string; + urgent?: boolean; + file?: string; + session?: string; + url?: string; +} + +export interface NotifyResult { + code: number; + out?: string; + err?: string; +} + +export type JsonPost = ( + apiUrl: string, + apiPath: string, + payload: unknown +) => Promise<{ status: number; error?: string }>; + +/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */ +export const postJson: JsonPost = (apiUrl, apiPath, payload) => { + const { username, password } = readCodemanCredentials(); + const url = new URL(apiPath, apiUrl); + const body = JSON.stringify(payload); + const transport = url.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }; + if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'POST', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + let text = ''; + res.on('data', (c) => (text += c)); + res.on('end', () => { + let error: string | undefined; + try { + error = (JSON.parse(text) as { error?: string }).error; + } catch { + /* not JSON */ + } + resolve({ status: res.statusCode ?? 0, error }); + }); + } + ); + req.on('error', () => resolve({ status: 0 })); + req.write(body); + req.end(); + }); +}; + +export async function runNotify( + opts: NotifyOptions, + env: NodeJS.ProcessEnv, + cwd: string, + post: JsonPost = postJson +): Promise { + const sessionId = opts.session || env.CODEMAN_SESSION_ID; + const apiUrl = opts.url || env.CODEMAN_API_URL; + if (!sessionId || !apiUrl) { + return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' }; + } + const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message }; + if (opts.urgent) payload.urgent = true; + if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file); + const { status, error } = await post( + apiUrl, + `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, + payload + ); + if (status >= 200 && status < 300) return { code: 0, out: 'Posted to Discord' }; + // The bridge being off is not a failure: the caller just says it in the terminal. + if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' }; + return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` }; +} diff --git a/src/cli.ts b/src/cli.ts index 5faee1ef..b28ee6a0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path'; import { homedir } from 'node:os'; import { dataPath } from './config/instance.js'; import { readCodemanCredentials } from './codeman-credentials.js'; +import { runNotify } from './cli-notify.js'; import { casePath } from './config/cases-dir.js'; import { assertValidBasePath } from './config/base-path.js'; import { @@ -92,53 +93,6 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin }); } -/** POST JSON to the Codeman API with the same credentials `attach` uses. Resolves to the HTTP status, or 0. */ -async function postJson( - apiUrl: string, - apiPath: string, - payload: unknown -): Promise<{ status: number; error?: string }> { - const { username, password } = readCodemanCredentials(); - const url = new URL(apiPath, apiUrl); - const body = JSON.stringify(payload); - const transport = url.protocol === 'https:' ? https : http; - return new Promise((resolve) => { - const headers: Record = { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }; - if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - const req = transport.request( - { - protocol: url.protocol, - hostname: url.hostname, - port: url.port, - method: 'POST', - path: `${url.pathname}${url.search}`, - rejectUnauthorized: false, - headers, - }, - (res) => { - let text = ''; - res.on('data', (c) => (text += c)); - res.on('end', () => { - let error: string | undefined; - try { - error = (JSON.parse(text) as { error?: string }).error; - } catch { - /* not JSON */ - } - resolve({ status: res.statusCode ?? 0, error }); - }); - } - ); - req.on('error', () => resolve({ status: 0 })); - req.write(body); - req.end(); - }); -} - program .command('notify ') .description( @@ -149,30 +103,10 @@ program .option('-s, --session ', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') .option('--url ', 'Codeman API URL (defaults to CODEMAN_API_URL)') .action(async (message: string, options) => { - const sessionId = options.session || process.env.CODEMAN_SESSION_ID; - const apiUrl = options.url || process.env.CODEMAN_API_URL; - if (!sessionId || !apiUrl) { - console.error(palette.err('✗ notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)')); - process.exit(1); - } - const payload: { message: string; urgent?: boolean; file?: string } = { message }; - if (options.urgent) payload.urgent = true; - if (options.file) payload.file = isAbsolute(options.file) ? options.file : join(process.cwd(), options.file); - const { status, error } = await postJson( - apiUrl, - `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, - payload - ); - if (status >= 200 && status < 300) { - console.log(palette.ok('✓ Posted to Discord')); - } else if (status === 409) { - console.log('The Discord bridge is off. Say it in the terminal instead.'); - } else { - console.error( - palette.err(`✗ Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}`) - ); - process.exit(1); - } + const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd()); + if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out); + if (err) console.error(palette.err(`✗ ${err}`)); + process.exit(code); }); program diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index c8e7da36..f730c8ce 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -46,6 +46,16 @@ export type DiscordStatus = Omit & { lastResult: DiscordResult | null; }; +/** + * A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at + * least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id. + */ +function resolveSessionId(ctx: SessionPort, id: string): string { + if (ctx.sessions.has(id) || id.length < 8) return id; + const hits = [...ctx.sessions.keys()].filter((k) => k.startsWith(id)); + return hits.length === 1 ? hits[0] : id; +} + /** * A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks * followed) lies inside the session's working folder. Anything else is refused, so a CLI cannot be @@ -137,7 +147,8 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe }); app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise> => { - const { id } = req.params as { id: string }; + const { id: rawId } = req.params as { id: string }; + const id = resolveSessionId(deps.ctx, rawId); const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); const session = findSessionOrFail(deps.ctx, id, req); let upload: { name: string; data: Uint8Array } | undefined; diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts new file mode 100644 index 00000000..b6b7f7d7 --- /dev/null +++ b/test/cli-notify.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +/** + * @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a + * recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js'; + +const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' }; + +function fake(status: number, error?: string) { + const post = vi.fn(async () => ({ status, error })); + return post; +} + +describe('runNotify', () => { + it('refuses outside a Codeman session, before any request', async () => { + const post = fake(200); + const r = await runNotify({ message: 'hi' }, {}, '/work', post); + expect(r.code).toBe(1); + expect(r.err).toMatch(/inside a Codeman session/); + expect(post).not.toHaveBeenCalled(); + }); + + it('posts the message to this session with the session id URL-encoded', async () => { + const post = fake(200); + const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post); + expect(r).toEqual({ code: 0, out: 'Posted to Discord' }); + expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', { + message: 'press the reset button', + }); + }); + + it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => { + const post = fake(200); + await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post); + expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' }); + await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post); + expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' }); + }); + + it('lets --session and --url override the environment', async () => { + const post = fake(200); + await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post); + expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']); + }); + + it('treats a disabled bridge (409) as success that says so', async () => { + const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409)); + expect(r.code).toBe(0); + expect(r.out).toMatch(/bridge is off/); + }); + + it('fails with the server error, or the status, or a plain message when nothing answered', async () => { + expect( + (await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err + ).toBe('Could not post to Discord: The file must be inside the session folder'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord'); + }); +}); + +describe('postJson (real transport)', () => { + let server: Server; + afterEach(() => new Promise((resolve) => server.close(() => resolve()))); + + async function serve(status: number, reply: unknown) { + const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' }; + server = createServer((req, res) => { + req.on('data', (c) => (seen.body += c)); + req.on('end', () => { + seen.method = req.method; + seen.url = req.url; + seen.type = req.headers['content-type']; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reply)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; + } + + it('sends a JSON POST and reports the status and the server error text', async () => { + const { seen, url } = await serve(400, { success: false, error: 'nope' }); + expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({ + status: 400, + error: 'nope', + }); + expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' }); + expect(JSON.parse(seen.body)).toEqual({ message: 'hi' }); + }); + + it('answers status 0 when nothing is listening', async () => { + const { url } = await serve(200, {}); + await new Promise((resolve) => server.close(() => resolve())); + expect((await postJson(url, '/x', {})).status).toBe(0); + server = createServer().listen(0); // so afterEach has something to close + }); +}); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts new file mode 100644 index 00000000..4c77b4bb --- /dev/null +++ b/test/discord-push-glue.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +/** + * @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no + * Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by + * kind. The bridge and routes are tested on their own; this pins the lines that make the feature + * exist. A real WebServer, never started, with the bridge replaced by a recorder. + * + * Port: N/A (no server start). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) })); +vi.mock('web-push', () => ({ + default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() }, +})); + +import { WebServer } from '../src/web/server.js'; +import { SseEvent } from '../src/web/sse-events.js'; + +type Internals = { + pushStore: { getAll: () => never[] }; + discordBridge: Record>; + sessions: Map; + mux: { capturePaneText?: (name: string) => string | null }; + sendPushNotifications: (event: string, data: Record) => Promise; + broadcast: (event: string, data: unknown) => void; +}; + +function server(): Internals { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; + s.pushStore = { getAll: () => [] }; + s.discordBridge = { + notify: vi.fn(async () => null), + sessionCreated: vi.fn(async () => undefined), + sessionRenamed: vi.fn(async () => undefined), + sessionDeleted: vi.fn(async () => undefined), + sessionIdle: vi.fn(async () => undefined), + }; + return s; +} + +beforeEach(() => sendNotification.mockClear()); + +describe('sendPushNotifications -> Discord', () => { + it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => { + const s = server(); + s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' }); + s.mux = { capturePaneText: () => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n' }; + await s.sendPushNotifications('hook:permission_prompt', { + sessionId: 's1', + sessionName: 'w1-app', + tool_name: 'Bash', + }); + expect(s.discordBridge.notify).toHaveBeenCalledTimes(1); + expect(s.discordBridge.notify.mock.calls[0][0]).toMatchObject({ + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', + tail: 'Which option?\n 1. Cap GPUs\n 2. Cap CPU', + }); + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => { + const s = server(); + s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' }); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' }); + expect(s.discordBridge.notify.mock.calls[0][0].tail).toBe('plain answer'); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' }); + expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined(); + }); + + it('sends nothing for an event Web Push does not carry', async () => { + const s = server(); + await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' }); + expect(s.discordBridge.notify).not.toHaveBeenCalled(); + }); +}); + +describe('broadcast -> Discord tab lifecycle', () => { + it('routes created, updated and deleted by kind, reading both payload shapes', () => { + const s = server(); + s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } }); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123); + s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); + expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); + s.broadcast(SseEvent.SessionDeleted, { id: 's1' }); + expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('s1'); + }); + + it('ignores an event with no session id', () => { + const s = server(); + s.broadcast(SseEvent.SessionDeleted, {}); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('tells the bridge whether an idle session is an external CLI: codex yes, claude and shell no', () => { + const s = server(); + s.sessions.set('cx', { mode: 'codex' }); + s.sessions.set('cl', { mode: 'claude' }); + s.sessions.set('sh', { mode: 'shell' }); + for (const id of ['cx', 'cl', 'sh', 'missing']) s.broadcast(SseEvent.SessionIdle, { id }); + expect(s.discordBridge.sessionIdle.mock.calls).toEqual([ + ['cx', true], + ['cl', false], + ['sh', false], + ['missing', false], + ]); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 5befb8e2..19f42846 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -122,6 +122,31 @@ describe('POST /api/sessions/:id/discord-notify', () => { bridge.stop(); }); + it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => { + const { app, bridge, ctx } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const ok = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ok.statusCode).toBe(200); + ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!); + const ambiguous = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ambiguous.statusCode).toBe(404); + const short = await app.inject({ + method: 'POST', + url: '/api/sessions/test/discord-notify', + payload: { message: 'hi' }, + }); + expect(short.statusCode).toBe(404); + bridge.stop(); + }); + it('rejects an empty or oversized message', async () => { const { app } = await harness(); const url = `/api/sessions/test-session-1/discord-notify`;