diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b08..5c5625260 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`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` + **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **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) @@ -189,7 +191,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` -**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) +**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage` via `sendInput(…, true)`; the bridge's own startup hint is typed with `fromUser: false`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title) **Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift. @@ -315,7 +317,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/README.md b/README.md index 3546ab39a..cfe3fa03c 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 <id> # tail output codeman task add "fix the failing test" # (t) queue a task codeman attach <path> # show an attachment card for a local file +codeman notify "<message>" [--urgent] [--file <path>] # 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/api-reference.md b/docs/api-reference.md index 4a0cb6b72..e1a0d7abf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1486,6 +1486,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. +## Discord bridge + +One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode. + +| Method | Path | Body | Notes | +| ------ | ------ | ------ | ------ | +| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | +| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. | +| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | + +Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)). + ## Diagnostics `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebba79736..5a7d73d2e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1364,3 +1364,16 @@ refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT not edits. Never retry ambiguous Enter delivery or clear an unverified composer. The ordinary SubmitVerifier uses the shared write boundary, so each re-press updates `lastSubmitAt` just like any other Enter. + +## Discord bridge + +`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md). + +- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`. +- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state. +- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false. +- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded. +- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires. +- **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first. +- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop. +- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`). diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md new file mode 100644 index 000000000..d757a32e8 --- /dev/null +++ b/docs/discord-bridge.md @@ -0,0 +1,138 @@ +# Discord bridge + +Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network. + +> [!WARNING] +> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password. + +## What it does + +- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex's permission and question dialogs are read off its screen. OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`. +- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them. +- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session. +- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress. +- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end. +- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`. +- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)). + +## Setup + +Do these once, in order. Never paste the bot token into a chat or a session. + +### 1. Create a private server + +1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**. +2. Invite nobody and create no invite links. +3. Create one text channel, for example `#codeman-cli`. + +### 2. Copy the IDs + +1. Open **User Settings**, **Advanced** and turn on **Developer Mode**. +2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID. + +### 3. Create the application + +1. Go to <https://discord.com/developers/applications> and choose **New Application**. +2. Name it `Codeman`. + +### 4. Configure the bot + +On the **Bot** tab: + +1. Choose **Reset Token** and copy it. It is shown once. +2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here. +3. Leave **Requires OAuth2 Code Grant** off. +4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off. + +### 5. Invite the bot with minimal permissions + +On **OAuth2**, **URL Generator**: + +1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else. +2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview): + + | Permission | Why | + | ------------------------ | ------------------------------------ | + | View Channels | See the parent channel | + | Send Messages | Post in the parent channel | + | Send Messages in Threads | Post in each tab's thread | + | Create Public Threads | Make a thread per tab | + | Read Message History | Read your replies | + | Add Reactions | Acknowledge a reply that was typed | + | Attach Files | Send a file from a CLI to the thread | + | Embed Links | Show previews for links in posts | + +3. Open the generated URL, choose your private server and authorise. +4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot. + +The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`. + +### 6. Optional: allow deleting threads + +Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads. + +### 7. Never grant + +Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission. + +### 8. Configure Codeman + +1. Open **Settings**, **Notifications**, then the **Discord** group. +2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist. +3. Choose the options below and press **Send test**. A test message should appear in the channel. + +The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE. + +### 9. Phone alerts + +In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network. + +## Options + +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | +| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | +| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | + +## Telling the CLIs + +A CLI only posts deliberately if it knows it can. While the bridge is on: + +- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway. +- **Every other CLI** is sent nothing unless you turn on `typedHint` (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives together with the first real prompt). With it on, the same text is sent once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. + +The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those. + +## Files + +- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them. +- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, which stops an upload of something outside it by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files. +- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it. + +## Security model + +- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped. +- A reply is reduced to one line, stripped of control characters and capped in length before it is typed. +- Replies are rate limited per session. +- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings. + +## Rotating and revoking + +- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**. +- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts. +- If the token is ever pasted somewhere it should not be, reset it straight away. + +## Troubleshooting + +| Symptom | Check | +| ----------------------------------- | ------------------------------------------------------------------------ | +| Test fails with 401 | The token is wrong or was reset. Paste the current one | +| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist | +| Thread is not deleted | The bot lacks Manage Threads on the parent channel | +| Nothing arrives on the phone | Check the Discord app's notification setting for the server | diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 0a6eb6eaa..3c68a096a 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,6 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder, which stops a file outside that folder being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or, only when `typedHint` is on, typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 8ca072165..e50d07390 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,15 +189,15 @@ 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 -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 values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the +bot token) 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. ## 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 000000000..445b9d6b4 --- /dev/null +++ b/docs/wiki/Discord-Bridge.md @@ -0,0 +1,104 @@ +# 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. A + question or permission prompt shows what is on the tab's screen right now, so you can see what is + being asked; a finished turn or an error carries the CLI's whole last message. +- **One thread per tab** in a parent channel, so a reply always reaches exactly one session. +- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter). +- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can + 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. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); 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 | +| Stale threads | Keep, archive or delete threads no tab uses | Keep | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell Claude how to reach you | On or off | On | +| Also tell other CLIs, as a first message | On or off | Off | + +A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions +kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines +of the screen instead. + +Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which +events** is Everything), and DeepSeek Harness sends a stop. The others (OpenCode, Gemini, Antigravity, Pi, +Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`; Codex's dialogs +are read off its screen. + +Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a +permission prompt, a question, an error, or a post an agent marked urgent. + +## Telling the agents + +An agent only posts deliberately if it knows it can: + +- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command). +- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a first message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle. + Restored tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) 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 + `<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with + `[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed. +- **Agent to you:** `codeman notify "message" --file <path>` 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 backs off, and the last error shows in Settings. + +## 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 3a0cd5663..0efad3c4d 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 fcdd7f93e..50dad790e 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/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 1d4aa1294..6aca89645 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -213,8 +213,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser notification display time**, default 8; both per device, and your OS may close a desktop notification sooner), the idle threshold that decides when a quiet session counts as needing you, and the server-wide -webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See -[Notifications And Approvals](Notifications-And-Approvals). +webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the +Discord group for the two-way bot (token, IDs, thread and ping options; see +[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals). ### Voice diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 0b85b925c..36a049093 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 000000000..5cdae57a7 --- /dev/null +++ b/src/cli-notify.ts @@ -0,0 +1,129 @@ +/** + * @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'; + +const REQUEST_TIMEOUT_MS = 30_000; + +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, + timeoutMs?: number +) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; 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, timeoutMs = REQUEST_TIMEOUT_MS) => { + 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((resolveOnce) => { + // Settle once: a response that stalls after its headers ends in 'close' without 'end'. + let settled = false; + const resolve: typeof resolveOnce = (v) => { + if (settled) return; + settled = true; + resolveOnce(v); + }; + const headers: Record<string, string | number> = { + 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, + timeout: timeoutMs, + }, + (res) => { + let text = ''; + res.on('data', (c) => (text += c)); + res.on('error', () => resolve({ status: 0 })); + res.on('close', () => resolve({ status: 0 })); + res.on('end', () => { + let error: string | undefined; + let delivery: { ok: boolean; error?: string } | undefined; + try { + const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } }; + error = body.error; + // A 200 only says Codeman took the request; `data.ok` says whether Discord took the post. + if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error }; + } catch { + /* not JSON */ + } + resolve({ status: res.statusCode ?? 0, error, delivery }); + }); + } + ); + // A server that takes the connection and never answers must not hang the agent: status 0 is "no answer". + req.on('timeout', () => req.destroy()); + 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<NotifyResult> { + 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, delivery } = await post( + apiUrl, + `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, + payload + ); + if (status >= 200 && status < 300) { + if (delivery?.ok === false) { + return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` }; + } + 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.' }; + if (status === 429) + return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' }; + 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 4682aa895..b28ee6a0d 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,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin }); } +program + .command('notify <message>') + .description( + "Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)" + ) + .option('--urgent', 'Ping the user') + .option('--file <path>', 'Send a file from the session folder with the message') + .option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') + .option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)') + .action(async (message: string, options) => { + 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 .command('attach <path>') .description('Show an attachment card for a local file') diff --git a/src/config-backup.ts b/src/config-backup.ts index f9e315aa0..e3425dc95 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [ 'clis.json', 'custom-model-hosts.json', 'webhook.json', + 'discord.json', 'intents.json', 'linked-cases.json', 'remote-hosts.json', @@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet<string> = new Set([ 'users.json', 'push-keys.json', 'webhook.json', + 'discord.json', 'intents.json', ]); /** Elsewhere a value is hidden when its own key looks like a credential. */ diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index f5529ba3b..2aa787cdc 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -80,6 +80,7 @@ const paramSpecSchema = z.union([ 'muxName', 'effortLevel', 'effortSettingsJson', + 'appendSystemPrompt', 'codemanPrefixedSessionId', 'launcherDefaultTarget', ]), @@ -224,6 +225,7 @@ const envExportSchema = z 'muxName', 'effortLevel', 'effortSettingsJson', + 'appendSystemPrompt', 'codemanPrefixedSessionId', 'launcherDefaultTarget', ]), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index c3061e78c..df63a256b 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -152,6 +152,7 @@ const CLAUDE: CliEntry = { // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). effortLevel: { type: 'engine', source: 'effortLevel' }, effortJson: { type: 'engine', source: 'effortSettingsJson' }, + discordHint: { type: 'engine', source: 'appendSystemPrompt' }, sessionId: { type: 'engine', source: 'sessionId' }, sessionName: { type: 'engine', source: 'sessionName' }, }, @@ -179,6 +180,12 @@ const CLAUDE: CliEntry = { { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + { + flag: '--append-system-prompt', + valueFrom: 'discordHint', + quote: 'single', + when: { param: 'discordHint', state: 'set' }, + }, ], }, { @@ -203,6 +210,12 @@ const CLAUDE: CliEntry = { { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + { + flag: '--append-system-prompt', + valueFrom: 'discordHint', + quote: 'single', + when: { param: 'discordHint', state: 'set' }, + }, ], }, ], diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 6707d4aa1..239ea5b1b 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -29,6 +29,8 @@ export type EngineValue = | 'muxName' | 'effortLevel' | 'effortSettingsJson' + /** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */ + | 'appendSystemPrompt' /** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */ | 'codemanPrefixedSessionId' /** diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts new file mode 100644 index 000000000..ff62fdd42 --- /dev/null +++ b/src/discord-bridge.ts @@ -0,0 +1,1316 @@ +/** + * @fileoverview Two-way Discord bridge: one thread per tab, alerts out, replies typed back in. + * + * Split like webhook-notify.ts, so the parts that matter are testable without a network: + * - pure: `sanitiseReply`, `threadNameFor`, `buildPost`, `discordConfigProblem`, `maskToken` + * - store: `~/.codeman/discord.json` (bot token, 0600 via tmp+rename) and + * `~/.codeman/discord-bridge.json` (sessionId -> threadId, not secret) + * - IO: `DiscordApi` (REST, injected fetch), `DiscordGateway` (inbound WebSocket, injected + * socket factory) and `DiscordBridge` (the rules that tie them to sessions) + * + * Rules the code keeps and the tests pin: + * - The bot token lives only in `discord.json`; it is never returned, logged, or put in an error. + * - Only messages from `allowedUserIds`, in `guildId`, inside a thread this bridge created, are + * acted on. Everything else is dropped without being read further. + * - A reply becomes one printable line plus Enter. Embedded newlines would submit early. A lone digit + * while a permission or question is pending goes as the key alone (see onGatewayMessage). + * - Posts never ping unless `pingMode` says so, and then only the allowlisted user ids. + * - A Discord outage or a bad token never reaches a session: every IO path catches and backs off. + * + * @module discord-bridge + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import WebSocket from 'ws'; +import { WEBHOOK_SCOPES, type WebhookUrgency } from './types/push.js'; +import { + DISCORD_ON_DELETE, + DISCORD_PING_MODES, + DISCORD_THREAD_CREATION, + type DiscordConfig, + type DiscordGatewayState, + type DiscordPingMode, + type DiscordResult, +} from './types/discord.js'; + +const CONFIG_FILE = 'discord.json'; +const THREADS_FILE = 'discord-bridge.json'; +const API = 'https://discord.com/api/v10'; +const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'; +const SEND_TIMEOUT_MS = 8000; +const MAX_CONTENT = 1900; +const MAX_THREAD_NAME = 100; +/** Longest reply typed into a session; Discord allows 2000, a CLI prompt rarely needs more than this. */ +export const MAX_REPLY_CHARS = 1000; +const REPLY_MIN_INTERVAL_MS = 1000; +const DEDUPE_WINDOW_MS = 3000; +/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, switched on in the Developer Portal). */ +const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15); +/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */ +const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]); +const SNOWFLAKE = /^\d{15,25}$/; +/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */ +const STALE_MIN_AGE_MS = 60 * 60 * 1000; +const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */ +const STALE_MAX_PER_SWEEP = 25; +const DISCORD_EPOCH_MS = 1420070400000; +/** Reading a CLI's transcript must not hold an alert back for long. */ +const FULL_TEXT_TIMEOUT_MS = 4000; + +export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { + enabled: false, + token: '', + guildId: '', + parentChannelId: '', + allowedUserIds: [], + threadCreation: 'lazy', + onSessionDelete: 'archive', + staleThreads: 'keep', + pingMode: 'critical', + scope: 'attention', + startupHint: true, + typedHint: false, +}; + +export interface DiscordMessage { + event: string; + title: string; + body: string; + urgency: WebhookUrgency; + sessionId?: string; + sessionName?: string; + /** The tail of what the CLI last printed, so the reader can see what it is asking. */ + tail?: string | (() => string); + /** + * The CLI's whole last message, read from its transcript. When it yields text the post carries that + * (split over as many posts as it needs, markdown kept) instead of the screen tail. + */ + fullText?: () => Promise<{ text: string; version?: string } | undefined>; + host?: string; + /** A file to upload with the post (already read and size-checked by the caller). */ + file?: DiscordFile; +} + +export interface DiscordFile { + name: string; + data: Uint8Array; +} + +// --------------------------------------------------------------------------- +// Pure +// --------------------------------------------------------------------------- + +/** Why `cfg` cannot be saved as an enabled config, or null. */ +export function discordConfigProblem(cfg: DiscordConfig): string | null { + if (cfg.token && (/\s/.test(cfg.token) || cfg.token.length > 200)) return 'The bot token looks wrong'; + if (cfg.guildId && !SNOWFLAKE.test(cfg.guildId)) return 'Guild ID must be a number (Copy ID in Discord)'; + if (cfg.parentChannelId && !SNOWFLAKE.test(cfg.parentChannelId)) return 'Channel ID must be a number'; + if (cfg.allowedUserIds.some((id) => !SNOWFLAKE.test(id))) return 'Each allowed user ID must be a number'; + if (cfg.allowedUserIds.length > 20) return 'At most 20 allowed user IDs'; + if (cfg.enabled) { + if (!cfg.token) return 'Add the bot token before enabling'; + if (!cfg.guildId || !cfg.parentChannelId) return 'Add the guild ID and parent channel ID before enabling'; + if (cfg.allowedUserIds.length === 0) return 'Add at least one allowed user ID: nobody could reply otherwise'; + } + return null; +} + +/** First four and last two characters, so the user can tell which token is saved. */ +export function maskToken(token: string): string { + return token.length > 12 ? `${token.slice(0, 4)}•••${token.slice(-2)}` : token ? '•••' : ''; +} + +const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** + * A Discord reply as one printable line plus Enter, or null when nothing printable is left. The caller + * drops the Enter for a lone digit answering a pending dialog. + */ +export function sanitiseReply(raw: string, files: string[] = []): string | null { + // eslint-disable-next-line no-control-regex + const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + const trimmed = clip(line.trim(), MAX_REPLY_CHARS); + // Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut. + const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : ''; + // eslint-disable-next-line no-control-regex + const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + return out ? `${out}\r` : null; +} + +export function threadNameFor(sessionName: string | undefined, sessionId: string): string { + const name = (sessionName ?? '').replace(/[\r\n]+/g, ' ').trim(); + return clip(name || sessionId.slice(0, 8), MAX_THREAD_NAME); +} + +/** Whether `urgency` earns a ping under `mode`. */ +export function shouldPing(mode: DiscordPingMode, urgency: WebhookUrgency, explicitUrgent = false): boolean { + if (mode === 'off') return false; + return mode === 'all' || explicitUrgent || urgency === 'critical'; +} + +export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency): boolean { + if (!cfg.enabled || !cfg.token) return false; + return cfg.scope === 'all' || urgency !== 'info'; +} + +/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */ +export const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_FILES_PER_REPLY = 5; +const DOWNLOAD_TIMEOUT_MS = 30_000; +/** Where a session's received files go, relative to its working folder. */ +export const RECEIVED_DIR = '.codeman-discord'; + +/** + * What a session is told about the bridge. Claude gets it as `--append-system-prompt`; every other + * CLI is sent it once as its first message, only when `typedHint` is on (see DiscordBridge.sessionIdle). No apostrophes: it is + * rendered into a shell command. + */ +export const DISCORD_STARTUP_HINT = + 'Codeman note: if you are blocked on something only the user can do (a physical step such as ' + + 'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' + + 'codeman notify "message that leads with the action" --urgent (lead with the action). The user reads a Discord thread for ' + + 'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' + + 'given in that line. Add --file <path> to send a file from your working folder. Not for routine progress.'; + +let activeStartupHint: string | undefined; +/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */ +export function getDiscordStartupHint(): string | undefined { + return activeStartupHint; +} + +/** + * The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and + * pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line. + * With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status + * and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option + * (`❯ 1. Yes`), so a question's choices stay. + */ +export function screenTail(screen: string, maxLines = 14, maxChars = 900, composerGlyph?: string): string { + let lines = screen + // eslint-disable-next-line no-control-regex + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '') + .split(/\r?\n/) + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l)); + if (composerGlyph) { + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) { + const row = lines[i].trimStart(); + if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) { + lines = lines.slice(0, i); + break; + } + } + } + let out = lines.slice(-maxLines); + while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1); + return out.join('\n').slice(-maxChars); +} + +/** + * Discord does not render Markdown tables: a `| a | b |` block shows as raw pipes. Each table outside a + * code fence becomes a code block with the columns padded to their widest cell (right-aligned where the + * separator row says `---:`), which a monospace font lines up. Bold and code marks inside a cell are + * dropped, since a code block would show them literally. + */ +export function tablesToCodeBlocks(text: string): string { + const cells = (row: string): string[] => { + let r = row.trim(); + if (r.startsWith('|')) r = r.slice(1); + if (r.endsWith('|') && !r.endsWith('\\|')) r = r.slice(0, -1); + return r.split(/(?<!\\)\|/).map((c) => + c + .replace(/\\\|/g, '|') + .replace(/\*\*|`/g, '') + .trim() + ); + }; + // Whitespace stripped first, so the pattern has none to backtrack over (a run of spaces made the old one quadratic). + const isSeparator = (row: string) => { + const r = row.replace(/\s+/g, ''); + return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r); + }; + const lines = text.split('\n'); + const out: string[] = []; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*```/.test(line)) fence = !fence; + if (fence || !line.includes('|') || i + 1 >= lines.length || !isSeparator(lines[i + 1])) { + out.push(line); + continue; + } + const header = cells(line); + const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left')); + const rows = [header]; + let j = i + 2; + for (; j < lines.length && lines[j].includes('|') && lines[j].trim() !== ''; j++) rows.push(cells(lines[j])); + const cols = Math.max(...rows.map((r) => r.length)); + const width = Array.from({ length: cols }, (_, c) => Math.max(1, ...rows.map((r) => [...(r[c] ?? '')].length))); + const pad = (v: string, c: number) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c])); + const fmt = (r: string[]) => + Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c)) + .join(' ') + .trimEnd(); + out.push('```', fmt(header), width.map((w) => '-'.repeat(w)).join(' '), ...rows.slice(1).map(fmt), '```'); + i = j - 1; + } + return out.join('\n'); +} + +/** + * Split a long message into posts of at most `max` characters, on blank lines first, then lines, then + * (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post + * renders on its own. At most `maxPosts`; the last carries a note when text is left over. + */ +export function splitForDiscord(text: string, max = 1900, maxPosts = 8): string[] { + const posts: string[] = []; + let cur = ''; + let fence = false; + let cut = false; + const flush = () => { + if (cur.trim()) posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd()); + cur = fence ? '```\n' : ''; + }; + const lines = text.replace(/\r\n/g, '\n').split('\n'); + for (let line of lines) { + if (posts.length >= maxPosts) { + cut = true; + break; + } + while (line.length > max - 8) { + if (cur.trim()) flush(); + cur += line.slice(0, max - 8) + '\n'; + line = line.slice(max - 8); + flush(); + if (posts.length >= maxPosts) { + cut = true; + break; + } + } + if (cut) break; + if (cur.length + line.length + 1 > max - 8) flush(); + cur += `${line}\n`; + if (/^\s*```/.test(line)) fence = !fence; + } + if (!cut && cur.trim()) posts.push(cur.trimEnd()); + if (cut && posts.length > 0) posts[posts.length - 1] += '\n*(The message continues in the tab.)*'; + return posts.slice(0, maxPosts); +} + +/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */ +export function safeAttachmentName(name: string): string { + const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, ''); + return clip(base, 80) || 'file'; +} + +/** Discord's own CDN over https: the only place an attachment URL may point. */ +export function isDiscordCdnUrl(raw: string): boolean { + try { + const u = new URL(raw); + return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname); + } catch { + return false; + } +} + +export interface DiscordPost { + content: string; + allowed_mentions: { parse: string[]; users: string[] }; +} + +export function buildPost(msg: DiscordMessage, cfg: DiscordConfig, explicitUrgent = false): DiscordPost { + const ping = shouldPing(cfg.pingMode, msg.urgency, explicitUrgent) ? cfg.allowedUserIds : []; + const mentions = ping.map((id) => `<@${id}>`).join(' '); + const head = `${mentions ? `${mentions} ` : ''}**${clip(msg.title, 120)}**`; + // Built here, not by the caller, so the screen is only read for a post that is really going out. + let tailText: string | undefined; + try { + tailText = typeof msg.tail === 'function' ? msg.tail() : msg.tail; + } catch { + tailText = undefined; + } + const fence = tailText?.trim() ? `\n\`\`\`\n${tailText.trim().replace(/```/g, "'''")}\n\`\`\`` : ''; + const host = msg.host ? ` (${clip(msg.host, 60)})` : ''; + const content = clip(`${head}${host}${msg.body ? `\n${msg.body}` : ''}${fence}`, MAX_CONTENT); + // Agent output and session names must not be able to @everyone the server; only the allowlisted ids ping. + return { content, allowed_mentions: { parse: [], users: ping } }; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export function discordConfigPath(configDir: string): string { + return join(configDir, CONFIG_FILE); +} + +const oneOf = <T extends string>(list: readonly T[], v: unknown, fallback: T): T => + (list as readonly unknown[]).includes(v) ? (v as T) : fallback; + +function coerce(raw: unknown): DiscordConfig { + const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>; + const d = DEFAULT_DISCORD_CONFIG; + return { + enabled: r.enabled === true, + token: typeof r.token === 'string' ? r.token : '', + guildId: typeof r.guildId === 'string' ? r.guildId : '', + parentChannelId: typeof r.parentChannelId === 'string' ? r.parentChannelId : '', + allowedUserIds: Array.isArray(r.allowedUserIds) + ? r.allowedUserIds.filter((x): x is string => typeof x === 'string') + : [], + threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation), + onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete), + staleThreads: oneOf(DISCORD_ON_DELETE, r.staleThreads, d.staleThreads), + pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode), + scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope), + startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true, + typedHint: r.typedHint === true, + }; +} + +function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> { + let timer: ReturnType<typeof setTimeout>; + return Promise.race([ + p, + new Promise<never>((_, reject) => { + timer = setTimeout(() => reject(new Error('timed out')), ms); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */ +class BridgeProblem extends Error {} + +export async function readDiscordConfig(configDir: string): Promise<DiscordConfig> { + try { + return coerce(JSON.parse(await fs.readFile(discordConfigPath(configDir), 'utf-8'))); + } catch { + return { ...DEFAULT_DISCORD_CONFIG }; + } +} + +async function writePrivate(target: string, data: unknown, mode: number): Promise<void> { + // A unique name: two writes at once (two admin tabs) must not share one tmp file. + const tmp = `${target}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode }); + await fs.rename(tmp, target); + } catch (err) { + await fs.unlink(tmp).catch(() => undefined); + throw err; + } +} + +/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */ +export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): Promise<void> { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); +} + +/** The thread map plus the parent channel it was built under. */ +async function readThreadFile(configDir: string): Promise<{ parent: string; threads: Record<string, string> }> { + try { + const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record<string, unknown>; + const threads: Record<string, string> = {}; + const saved = typeof raw.threads === 'object' && raw.threads !== null ? raw.threads : {}; + for (const [k, v] of Object.entries(saved)) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v; + return { parent: typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '', threads }; + } catch { + return { parent: '', threads: {} }; + } +} + +export async function readThreadMap(configDir: string): Promise<Record<string, string>> { + return (await readThreadFile(configDir)).threads; +} + +async function writeThreadMap( + configDir: string, + parentChannelId: string, + threads: Record<string, string> +): Promise<void> { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(join(configDir, THREADS_FILE), { parentChannelId, threads }, 0o600); +} + +// --------------------------------------------------------------------------- +// IO: REST +// --------------------------------------------------------------------------- + +export interface DiscordThreadInfo { + id: string; + parent_id?: string; + owner_id?: string; + thread_metadata?: { archived?: boolean }; +} + +export type DiscordFetch = (target: URL, init: RequestInit) => Promise<Response>; + +export class DiscordHttpError extends Error { + constructor( + readonly status: number, + /** Discord's own error code (10003 unknown channel, 50001 missing access ...). */ + readonly code?: number + ) { + super(`Discord HTTP ${status}${code ? ` (code ${code})` : ''}`); + } +} + +/** Thin REST client. The token goes only in the Authorization header and never into a message. */ +export class DiscordApi { + constructor( + private readonly token: () => string, + private readonly fetchImpl: DiscordFetch, + private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)) + ) {} + + async request<T = unknown>(method: string, path: string, body?: unknown, retried = false): Promise<T | null> { + const isForm = typeof FormData !== 'undefined' && body instanceof FormData; + const res = await this.fetchImpl(new URL(`${API}${path}`), { + method, + headers: { + Authorization: `Bot ${this.token()}`, + // A form body sets its own multipart Content-Type (with the boundary). + ...(isForm ? {} : { 'Content-Type': 'application/json' }), + 'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)', + }, + body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + if (res.status === 429 && !retried) { + const j = (await res.json().catch(() => ({}))) as { retry_after?: number }; + await this.sleep(Math.min(Math.max((j.retry_after ?? 1) * 1000, 100), 10_000)); + return this.request<T>(method, path, body, true); + } + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { code?: number }; + throw new DiscordHttpError(res.status, j.code); + } + if (res.status === 204) return null; + return (await res.json().catch(() => null)) as T | null; + } + + createThread(parentId: string, name: string): Promise<{ id: string } | null> { + // type 11 = public thread. A week of inactivity before Discord archives it. + return this.request('POST', `/channels/${parentId}/threads`, { name, type: 11, auto_archive_duration: 10080 }); + } + post(channelId: string, p: DiscordPost): Promise<{ id: string } | null> { + return this.request('POST', `/channels/${channelId}/messages`, p); + } + postWithFile(channelId: string, p: DiscordPost, file: DiscordFile): Promise<{ id: string } | null> { + const form = new FormData(); + form.append('payload_json', JSON.stringify(p)); + form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name)); + return this.request('POST', `/channels/${channelId}/messages`, form); + } + rename(threadId: string, name: string): Promise<unknown> { + return this.request('PATCH', `/channels/${threadId}`, { name }); + } + archive(threadId: string): Promise<unknown> { + return this.request('PATCH', `/channels/${threadId}`, { archived: true }); + } + remove(threadId: string): Promise<unknown> { + return this.request('DELETE', `/channels/${threadId}`); + } + botUserId(): Promise<{ id: string } | null> { + return this.request('GET', '/users/@me'); + } + /** Active threads of the guild (all channels: the caller filters by parent). */ + activeThreads(guildId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/guilds/${guildId}/threads/active`); + } + /** The most recent archived public threads of a channel (one page). */ + archivedThreads(parentId: string): Promise<{ threads: DiscordThreadInfo[] } | null> { + return this.request('GET', `/channels/${parentId}/threads/archived/public?limit=100`); + } + react(channelId: string, messageId: string, emoji: string): Promise<unknown> { + return this.request( + 'PUT', + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` + ); + } +} + +// --------------------------------------------------------------------------- +// IO: gateway +// --------------------------------------------------------------------------- + +export interface GatewayMessage { + id: string; + channel_id: string; + guild_id?: string; + content: string; + author: { id: string; bot?: boolean }; + attachments?: Array<{ id: string; filename: string; size: number; url: string }>; +} + +/** The subset of `ws` the gateway uses, so tests can drive it without a socket. */ +export interface GatewaySocket { + send(data: string): void; + close(code?: number): void; + on(event: 'message', cb: (data: { toString(): string }) => void): void; + on(event: 'close', cb: (code: number) => void): void; + on(event: 'error', cb: (err: Error) => void): void; +} +export type SocketFactory = (url: string) => GatewaySocket; + +const defaultSocket: SocketFactory = (url) => new WebSocket(url) as unknown as GatewaySocket; + +/** + * Inbound half: identify, heartbeat, resume and reconnect with backoff. Only MESSAGE_CREATE is + * surfaced. A close code that retrying cannot fix (bad token, intent not enabled) stops it and + * records `lastError` for the Settings status line. + */ +export class DiscordGateway { + state: DiscordGatewayState = 'off'; + lastError: string | null = null; + private sock: GatewaySocket | null = null; + private seq: number | null = null; + private sessionId: string | null = null; + private resumeUrl: string | null = null; + private hb: ReturnType<typeof setInterval> | null = null; + private retry: ReturnType<typeof setTimeout> | null = null; + private acked = true; + private attempts = 0; + private stopped = true; + + constructor( + private readonly token: () => string, + private readonly onMessage: (m: GatewayMessage) => void, + private readonly socketFactory: SocketFactory = defaultSocket, + private readonly backoffMs: (attempt: number) => number = (n) => Math.min(1000 * 2 ** n, 60_000) + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + this.state = 'off'; + this.clearTimers(); + const s = this.sock; + this.sock = null; + try { + s?.close(1000); + } catch { + /* already closed */ + } + } + + private clearTimers(): void { + if (this.hb) clearInterval(this.hb); + if (this.retry) clearTimeout(this.retry); + this.hb = this.retry = null; + } + + private connect(): void { + if (this.stopped) return; + this.state = 'connecting'; + let sock: GatewaySocket; + try { + sock = this.socketFactory(this.resumeUrl ?? GATEWAY_URL); + } catch { + this.scheduleReconnect(); + return; + } + this.sock = sock; + sock.on('message', (data) => this.handle(sock, data.toString())); + sock.on('error', () => undefined); // 'close' follows and drives the reconnect + sock.on('close', (code) => { + if (this.sock !== sock) return; // a replaced socket's late close + this.sock = null; + this.clearTimers(); + if (this.stopped) return; + if (FATAL_CLOSE_CODES.has(code)) { + this.state = 'error'; + this.lastError = + code === 4004 + ? 'Discord refused the bot token' + : code === 4014 + ? 'Message Content Intent is off in the Developer Portal' + : `Discord closed the connection (${code})`; + return; + } + this.scheduleReconnect(); + }); + } + + private scheduleReconnect(): void { + if (this.stopped) return; + this.state = 'connecting'; + this.retry = setTimeout(() => this.connect(), this.backoffMs(this.attempts++)); + } + + private handle(sock: GatewaySocket, raw: string): void { + // `ws` keeps emitting while a socket closes: a frame after stop() or from a replaced socket must start nothing. + if (this.stopped || sock !== this.sock) return; + let p: { op: number; d?: unknown; s?: number | null; t?: string | null }; + try { + p = JSON.parse(raw); + } catch { + return; + } + if (typeof p.s === 'number') this.seq = p.s; + switch (p.op) { + case 10: { + const interval = Number((p.d as { heartbeat_interval?: number })?.heartbeat_interval) || 41_250; + this.acked = true; + this.hb = setInterval(() => { + if (!this.acked) { + // Zombie connection: no ack since the last beat. Resume on a fresh socket. + try { + sock.close(4000); + } catch { + /* already closed */ + } + return; + } + this.acked = false; + sock.send(JSON.stringify({ op: 1, d: this.seq })); + }, interval); + if (this.sessionId && this.seq !== null) { + sock.send(JSON.stringify({ op: 6, d: { token: this.token(), session_id: this.sessionId, seq: this.seq } })); + } else { + sock.send( + JSON.stringify({ + op: 2, + d: { + token: this.token(), + intents: GATEWAY_INTENTS, + properties: { os: 'linux', browser: 'codeman', device: 'codeman' }, + }, + }) + ); + } + break; + } + case 11: + this.acked = true; + break; + case 1: + sock.send(JSON.stringify({ op: 1, d: this.seq })); + break; + case 7: // server asked us to reconnect (resumable) + sock.close(4000); + break; + case 9: // invalid session: resumable only if d === true + if (p.d !== true) { + this.sessionId = null; + this.seq = null; + this.resumeUrl = null; + } + sock.close(4000); + break; + case 0: + if (p.t === 'READY') { + const d = p.d as { session_id?: string; resume_gateway_url?: string }; + this.sessionId = d.session_id ?? null; + this.resumeUrl = d.resume_gateway_url ? `${d.resume_gateway_url}/?v=10&encoding=json` : null; + this.state = 'connected'; + this.lastError = null; + this.attempts = 0; + } else if (p.t === 'RESUMED') { + this.state = 'connected'; + this.attempts = 0; + } else if (p.t === 'MESSAGE_CREATE') { + this.onMessage(p.d as GatewayMessage); + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// The bridge +// --------------------------------------------------------------------------- + +/** + * Create or re-verify `<workingDir>/.codeman-discord`, or null when it is not safe to write there. + * Same safeguards as the prompt uploads folder: the workspace is probed with the bounded probe before + * the first disk touch (a dead mount must not hang the event loop), a symlink or a non-directory at + * the folder is refused (an agent could plant `.codeman-discord -> ~/.ssh`), and the folder ignores + * itself with a `.gitignore` of `*`, written once with O_EXCL so an `git add -A` never commits what was + * sent and a file already there stays the user's. + */ +export async function ensureReceivedDir(workingDir: string): Promise<string | null> { + if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') return null; + const dir = join(workingDir, RECEIVED_DIR); + try { + const st = await fs.lstat(dir); + if (st.isSymbolicLink() || !st.isDirectory()) return null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null; + try { + await fs.mkdir(dir); + } catch (mkErr) { + if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') return null; + const again = await fs.lstat(dir).catch(() => null); + if (!again || again.isSymbolicLink() || !again.isDirectory()) return null; + } + } + const ignore = join(dir, '.gitignore'); + try { + await fs.writeFile(ignore, '*\n', { flag: 'wx' }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return dir; + // An empty ignore file would read as the user's next time, so take it back. + await fs.rm(ignore, { force: true }).catch(() => undefined); + return null; + } + return dir; +} + +export interface DiscordBridgeDeps { + configDir: string; + fetchImpl: DiscordFetch; + /** + * Types `text` into the session (a line ending in Enter, or a lone digit for a dialog). False when the + * session is gone. `fromUser` is true only for what a person typed (a Discord reply), false for text + * Codeman types itself. + */ + sendInput: (sessionId: string, text: string, fromUser: boolean) => Promise<boolean>; + /** True once the tab's agent has exited: tmux still accepts keys for a dead pane, so a tick would lie. */ + agentExited?: (sessionId: string) => boolean; + /** True while the session shows a permission or question dialog, which acts on a bare keypress. */ + awaitingChoice?: (sessionId: string) => boolean; + /** A session's working folder, where received files are saved. Undefined when it is gone or remote. */ + sessionDir?: (sessionId: string) => string | undefined; + socketFactory?: SocketFactory; + now?: () => number; + sleep?: (ms: number) => Promise<void>; + backoffMs?: (attempt: number) => number; +} + +export class DiscordBridge { + private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG }; + private threads: Record<string, string> = {}; + private loaded = false; + private gateway: DiscordGateway | null = null; + /** The token the running gateway was started with ('' when none). */ + private gatewayToken = ''; + private lastSent = new Map<string, number>(); + private lastReply = new Map<string, number>(); + /** The last name seen per session, so only a real rename touches the thread. */ + private names = new Map<string, string>(); + /** Brand-new sessions that have not yet been sent the startup hint (non-Claude CLIs). */ + private fresh = new Set<string>(); + private pending = new Map<string, Promise<string | DiscordResult>>(); + /** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */ + private closedWhilePending = new Set<string>(); + /** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */ + private lastFull = new Map<string, string>(); + /** The parent channel the thread map was built under. */ + private threadsParent = ''; + /** True from boot until the restored tabs exist (see hold). */ + private held = false; + /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ + private persistChain: Promise<void> = Promise.resolve(); + /** Per-thread chain of inbound messages (see onGatewayMessage). */ + private inbound = new Map<string, Promise<void>>(); + private mapLoad?: Promise<void>; + private sweepTimer?: ReturnType<typeof setInterval>; + /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */ + private firstLoad?: Promise<void>; + private last: DiscordResult | null = null; + private readonly api: DiscordApi; + private readonly now: () => number; + + constructor(private readonly deps: DiscordBridgeDeps) { + this.now = deps.now ?? Date.now; + this.api = new DiscordApi(() => this.cfg.token, deps.fetchImpl, deps.sleep); + } + + get lastResult(): DiscordResult | null { + return this.last; + } + get gatewayState(): DiscordGatewayState { + return this.gateway?.state ?? 'off'; + } + get gatewayError(): string | null { + return this.gateway?.lastError ?? null; + } + + async ensureLoaded(): Promise<void> { + if (this.loaded) return; + this.firstLoad ??= this.reload(); + await this.firstLoad; + } + + /** Forget thread entries for tabs that no longer exist (dropped without a delete, for example). Once, at boot. */ + async prune(liveIds: Iterable<string>): Promise<void> { + await this.ensureLoaded(); + const live = new Set(liveIds); + if (live.size === 0) return; // nothing restored: more likely a failed restore than an empty server + const stale = Object.keys(this.threads).filter((id) => !live.has(id)); + if (stale.length === 0) return; + for (const id of stale) delete this.threads[id]; + await this.persistThreads(); + } + + /** + * Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after + * the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one + * (its map entry may not be written yet), or anything while the map is empty (a lost map would make + * every thread look stale). Returns how many it archived or deleted. Never throws. + */ + async sweepStaleThreads(): Promise<number> { + try { + await this.ensureLoaded(); + const c = this.cfg; + if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId) return 0; + if (Object.keys(this.threads).length === 0 || this.pending.size > 0) return 0; + const me = (await this.api.botUserId())?.id; + if (!me) return 0; + const known = new Set(Object.values(this.threads)); + const cutoff = this.now() - STALE_MIN_AGE_MS; + const stale = (list: DiscordThreadInfo[] | undefined, archived: boolean) => + (list ?? []) + .filter( + (t) => + t.parent_id === c.parentChannelId && + t.owner_id === me && + SNOWFLAKE.test(t.id) && + !known.has(t.id) && + Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff && + // An archived thread has nothing left to archive. + !(archived && c.staleThreads === 'archive') + ) + .map((t) => t.id); + const ids = [ + ...stale((await this.api.activeThreads(c.guildId))?.threads, false), + ...(c.staleThreads === 'delete' + ? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true) + : []), + ].slice(0, STALE_MAX_PER_SWEEP); + let done = 0; + for (const id of ids) { + const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then( + () => true, + () => false + ); + if (ok) done++; + } + return done; + } catch { + return 0; + } + } + + /** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */ + startStaleSweep(): void { + clearInterval(this.sweepTimer); + void this.sweepStaleThreads(); + this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS); + this.sweepTimer.unref?.(); + } + + /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */ + hold(): void { + this.held = true; + } + + /** Boot is done: start the gateway if the bridge is on. */ + async release(): Promise<void> { + if (!this.held) return; + this.held = false; + await this.reload(); + } + + /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ + async reload(): Promise<void> { + this.cfg = await readDiscordConfig(this.deps.configDir); + // The thread map is read once, by whoever gets here first; everyone else waits for that read. A second + // read finishing later would replace the map and drop threads made in between. + this.mapLoad ??= readThreadFile(this.deps.configDir).then((saved) => { + this.threads = { ...saved.threads, ...this.threads }; + this.threadsParent ||= saved.parent; + this.loaded = true; + }); + await this.mapLoad; + // Threads belong to the channel they were made under: a new parent channel (or guild) starts afresh, + // or every tab would keep posting into the old one. + const parent = this.cfg.parentChannelId; + if (parent && parent !== this.threadsParent) { + this.threads = {}; + this.pending.clear(); + this.closedWhilePending.clear(); + this.threadsParent = parent; + await this.persistThreads(); + } + activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; + // A save that changed neither the switch nor the token keeps the gateway session, so it can resume. + // A gateway that gave up (a fatal close, such as a missing intent) is replaced, or a fix could never take. + const want = this.cfg.enabled && this.cfg.token && !this.held ? this.cfg.token : ''; + if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken) return; + this.gateway?.stop(); + this.gateway = null; + this.gatewayToken = want; + if (want) { + this.gateway = new DiscordGateway( + () => this.cfg.token, + (m) => void this.onGatewayMessage(m).catch(() => undefined), + this.deps.socketFactory, + this.deps.backoffMs + ); + this.gateway.start(); + } + } + + stop(): void { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + this.gateway?.stop(); + this.gateway = null; + this.gatewayToken = ''; + activeStartupHint = undefined; + this.lastSent.clear(); + this.lastReply.clear(); + this.names.clear(); + this.fresh.clear(); + this.lastFull.clear(); + this.inbound.clear(); + } + + private fail(err: unknown): DiscordResult { + const status = err instanceof DiscordHttpError ? err.status : undefined; + const code = err instanceof DiscordHttpError ? err.code : undefined; + const error = + status === 401 + ? 'The bot token was refused' + : status === 403 || code === 50001 + ? 'The bot cannot see the parent channel (check its role and the channel ID)' + : status === 404 + ? 'Channel not found (check the IDs)' + : status + ? `HTTP ${status}` + : err instanceof BridgeProblem + ? err.message + : (err as { name?: string })?.name === 'TimeoutError' + ? 'Timed out' + : 'Network error'; + return { ok: false, status, error, at: this.now() }; + } + + private persistThreads(): Promise<void> { + this.persistChain = this.persistChain + .then(() => writeThreadMap(this.deps.configDir, this.threadsParent, { ...this.threads })) + .catch(() => undefined); + return this.persistChain; + } + + /** The thread for a session, creating it when absent. Concurrent callers share one creation. */ + private threadFor(sessionId: string, name: string | undefined): Promise<string | DiscordResult> { + const existing = this.threads[sessionId]; + if (existing) return Promise.resolve(existing); + const inflight = this.pending.get(sessionId); + if (inflight) return inflight; + const parent = this.cfg.parentChannelId; + const mine: { p?: Promise<string | DiscordResult> } = {}; + const p = (mine.p = (async () => { + try { + const t = await this.api.createThread(parent, threadNameFor(name, sessionId)); + if (!t?.id) return this.fail(new BridgeProblem('Discord did not return a thread')); + // A save moved the bridge to another channel meanwhile: this thread belongs to the old one. + if (parent !== this.cfg.parentChannelId) { + await this.closeThread(t.id); // made in the old channel: tidy it + return this.fail(new BridgeProblem('The parent channel changed')); + } + if (this.closedWhilePending.has(sessionId)) { + await this.closeThread(t.id); + return this.fail(new BridgeProblem('The tab was closed')); + } + this.threads[sessionId] = t.id; + await this.persistThreads(); + return t.id; + } catch (err) { + return this.fail(err); + } finally { + // Only this creation's own entry: a newer one for the same tab may have replaced it. + if (this.pending.get(sessionId) === mine.p) { + this.pending.delete(sessionId); + this.closedWhilePending.delete(sessionId); + } + } + })()); + this.pending.set(sessionId, p); + return p; + } + + /** Post an alert. Session-less messages go to the parent channel. Never throws. */ + async notify( + msg: DiscordMessage, + opts: { urgent?: boolean; deliberate?: boolean } = {} + ): Promise<DiscordResult | null> { + const explicitUrgent = opts.urgent === true; + await this.ensureLoaded(); + if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; + const key = `${msg.event}:${msg.sessionId ?? ''}`; + const t = this.now(); + const prev = this.lastSent.get(key); + // A deliberate post (an agent's own `codeman notify`) is never deduped: a second, different message is real. + // Automatic events are, and say so, which is not the same answer as "the bridge is off" (null). + if (!opts.deliberate && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) { + return { ok: true, deduped: true, at: t }; + } + this.lastSent.set(key, t); + if (this.lastSent.size > 256) { + for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); + } + let chunks: string[] = []; + // Remembered only once the first post is out: a failed post must leave the message to be sent again. + let posted: string | undefined; + if (msg.fullText) { + try { + const got = await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS); + const text = got?.text.trim(); + const sid = msg.sessionId; + if (text && sid) { + // Keyed on the text and the turn it came from, so a later turn that ends on the same words is posted. + const key = `${got?.version ?? ''}\u0000${text}`; + msg = { ...msg, tail: undefined }; + if (this.lastFull.get(sid) !== key) { + chunks = splitForDiscord(tablesToCodeBlocks(text)); + posted = key; + } // else: said already, so only the header goes + } + } catch { + // no transcript to read: the screen tail stands in + } + } + const post = buildPost(msg, this.cfg, explicitUrgent); + // One post when the head and the first part fit together, the rest follow without a ping. + if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) { + post.content += `\n${chunks.shift()}`; + } + try { + let target = this.cfg.parentChannelId; + if (msg.sessionId) { + const thread = await this.threadFor(msg.sessionId, msg.sessionName); + if (typeof thread !== 'string') return (this.last = thread); + target = thread; + } + const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post)); + try { + await send(target); + } catch (err) { + // The thread was deleted by hand: forget it, make a new one, once. + if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) { + delete this.threads[msg.sessionId]; + const fresh = await this.threadFor(msg.sessionId, msg.sessionName); + if (typeof fresh !== 'string') return (this.last = fresh); + await send(fresh); + } else { + throw err; + } + } + if (posted && msg.sessionId) this.lastFull.set(msg.sessionId, posted); + for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part); + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + return (this.last = this.fail(err)); + } + } + + /** A deliberate test send to the parent channel: bypasses `enabled`, scope and dedupe. */ + async sendTest(cfg: DiscordConfig, host?: string): Promise<DiscordResult> { + // Its own client: swapping this.cfg would undo a save that lands while the post is in flight. + const api = new DiscordApi(() => cfg.token, this.deps.fetchImpl, this.deps.sleep); + try { + await api.post( + cfg.parentChannelId, + buildPost( + { + event: 'discord:test', + title: 'Codeman test notification', + body: 'If you can read this, the Discord bridge can post. Replies in session threads are typed into that tab.', + urgency: 'info', + host, + }, + { ...cfg, pingMode: 'off' } + ) + ); + return (this.last = { ok: true, at: this.now() }); + } catch (err) { + return (this.last = this.fail(err)); + } + } + + /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ + async sessionCreated(sessionId: string, name: string | undefined, createdAt?: number): Promise<void> { + await this.ensureLoaded(); + // Only a tab made just now: the same event also fires for sessions restored at server start. + if (createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); + if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; + const made = await this.threadFor(sessionId, name); + if (typeof made !== 'string') this.last = made; + } + + /** + * First idle of a brand-new non-Claude session means its prompt is up: type the startup hint once. + * Claude gets the same text as a launch flag instead, and a shell must never receive it. + */ + async sessionIdle(sessionId: string, hintable: boolean): Promise<void> { + if (!this.fresh.has(sessionId)) return; + if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) { + this.fresh.delete(sessionId); // no longer new: turning the hint on later must not reach this tab + return; + } + // A dialog is up (codex's trust prompt reads as idle): Enter would pick its highlighted option, so wait for the next idle. + if (this.deps.awaitingChoice?.(sessionId)) return; + this.fresh.delete(sessionId); + const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`); + // fromUser false: this is Codeman talking, so it must not auto-name the tab or count as the person's input. + if (text) await this.deps.sendInput(sessionId, text, false).catch(() => false); + } + + async sessionRenamed(sessionId: string, name: string | undefined): Promise<void> { + const seen = this.names.get(sessionId); + this.names.set(sessionId, name ?? ''); + // First sight only records the name: the thread was made with it, so a PATCH would spend a rename for nothing. + if (seen === undefined || seen === (name ?? '')) return; + const thread = this.threads[sessionId]; + if (!thread || !this.cfg.enabled) return; + await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined); + } + + /** + * Drop the per-tab state only, keeping the thread and its map entry. For a session that is discarded + * but can come back under the same id (a failed reboot-restore rebuild stays on offer for a retry). + * Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server. + */ + forgetTab(sessionId: string): void { + this.lastReply.delete(sessionId); + this.names.delete(sessionId); + this.fresh.delete(sessionId); + this.lastFull.delete(sessionId); + } + + /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ + async sessionDeleted(sessionId: string): Promise<void> { + this.forgetTab(sessionId); + // A thread still being created is closed by its creation, which finds no entry to remove here. + if (this.pending.has(sessionId)) this.closedWhilePending.add(sessionId); + const thread = this.threads[sessionId]; + if (!thread) return; + delete this.threads[sessionId]; + await this.persistThreads(); + await this.closeThread(thread); + } + + /** Apply `onSessionDelete` to a thread. */ + private async closeThread(thread: string): Promise<void> { + if (!this.cfg.enabled) return; + if (this.cfg.onSessionDelete === 'delete') await this.api.remove(thread).catch(() => undefined); + else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined); + } + + private say(channelId: string, content: string): Promise<unknown> { + return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined); + } + + /** + * Save a reply's attachments under the session's working folder and return the paths. Only Discord + * CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is + * named in `refused`, so the person is not left guessing why a file did not arrive. + */ + private async saveAttachments(sessionId: string, m: GatewayMessage): Promise<{ paths: string[]; refused?: string }> { + const list = m.attachments ?? []; + if (list.length === 0) return { paths: [] }; + const dir = this.deps.sessionDir?.(sessionId); + if (!dir) return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' }; + // The message id goes into a file name, so it must be a snowflake and nothing else. + if (!SNOWFLAKE.test(m.id)) return { paths: [], refused: 'That message could not be saved.' }; + const target = await ensureReceivedDir(dir); + if (!target) return { paths: [], refused: 'The tab folder is not safe to write to, so no file was saved.' }; + const paths: string[] = []; + const skipped: string[] = []; + for (const [i, a] of list.entries()) { + const name = safeAttachmentName(a.filename ?? 'file'); + if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) { + skipped.push(name); + continue; + } + try { + const res = await this.deps.fetchImpl(new URL(a.url), { + redirect: 'manual', + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null; + if (!buf || buf.byteLength > MAX_FILE_BYTES) { + skipped.push(name); + continue; + } + const dest = join(target, `${m.id}-${name}`); + // 'wx' (O_EXCL) also refuses a symlink planted at the name. + await fs.writeFile(dest, buf, { mode: 0o600, flag: 'wx' }); + paths.push(dest); + } catch { + skipped.push(name); + } + } + return { + paths, + refused: skipped.length + ? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}` + : undefined, + }; + } + + /** Inbound reply. One chain per thread, so a reply that waits on a file download cannot be overtaken by a later one. */ + async onGatewayMessage(m: GatewayMessage): Promise<void> { + const key = String(m.channel_id); + const run = (this.inbound.get(key) ?? Promise.resolve()).then(() => this.handleMessage(m)).catch(() => undefined); + this.inbound.set(key, run); + await run; + if (this.inbound.get(key) === run) this.inbound.delete(key); + } + + /** Every gate here drops silently: a stranger learns nothing. */ + private async handleMessage(m: GatewayMessage): Promise<void> { + if (!this.cfg.enabled || m.author?.bot) return; + if (m.guild_id !== this.cfg.guildId) return; + if (!this.cfg.allowedUserIds.includes(m.author?.id)) return; + const sessionId = Object.keys(this.threads).find((k) => this.threads[k] === m.channel_id); + if (!sessionId) return; + const t = this.now(); + const prev = this.lastReply.get(sessionId); + if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) { + // Too soon after the last reply: say it was not typed, instead of dropping it silently. + await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined); + return; + } + if (this.deps.agentExited?.(sessionId)) { + await this.say(m.channel_id, 'The agent in that tab has exited, so nothing was typed.'); + return; + } + this.lastReply.set(sessionId, t); + const saved = await this.saveAttachments(sessionId, m); + const text = sanitiseReply(m.content ?? '', saved.paths); + if (!text) { + if (saved.refused) await this.say(m.channel_id, saved.refused); + return; + } + if (saved.refused) await this.say(m.channel_id, saved.refused); + // A lone digit answers a dialog by itself: an Enter after it would land on whatever comes next. + const keys = /^\d\r$/.test(text) && this.deps.awaitingChoice?.(sessionId) ? text.slice(0, -1) : text; + const ok = await this.deps.sendInput(sessionId, keys, true).catch(() => false); + const reply = ok + ? this.api.react(m.channel_id, m.id, '✅') + : this.api.post(m.channel_id, { + content: 'That tab is gone, so nothing was typed.', + allowed_mentions: { parse: [], users: [] }, + }); + await reply.catch(() => undefined); + } +} diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index 0c82c4bd2..c5b1aa7cc 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -17,6 +17,7 @@ * @module session-cli-registry-bridge */ +import { getDiscordStartupHint } from './discord-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; @@ -248,6 +249,10 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri engineValues.effortSettingsJson = JSON.stringify(settingsObj); } + // The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its + // `startupHint` setting are on. Claude-only in practice, like the settings object above. + engineValues.appendSystemPrompt = getDiscordStartupHint(); + // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means // "known to be unresolvable" and must not probe. The probe only ever runs from diff --git a/src/types/discord.ts b/src/types/discord.ts new file mode 100644 index 000000000..ded66bbcb --- /dev/null +++ b/src/types/discord.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Discord bridge type definitions. + * + * The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart + * from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the + * route can import them alone, as the webhook's equivalents live in `push.ts`. + * + * Key exports: + * - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists + * - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret) + * - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports + * + * No dependencies on other domain modules but `push.ts` (the shared scope list). + */ + +import type { WebhookScope } from './push.js'; + +export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const; +export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number]; +export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const; +export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number]; +export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const; +export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number]; + +export interface DiscordConfig { + enabled: boolean; + token: string; + guildId: string; + parentChannelId: string; + allowedUserIds: string[]; + threadCreation: DiscordThreadCreation; + onSessionDelete: DiscordOnDelete; + /** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */ + staleThreads: DiscordOnDelete; + pingMode: DiscordPingMode; + /** `attention` skips info-level events (a finished response); `all` sends them too. */ + scope: WebhookScope; + /** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */ + startupHint: boolean; + /** + * Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a + * model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt. + */ + typedHint: boolean; +} + +export interface DiscordResult { + ok: boolean; + /** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */ + deduped?: boolean; + status?: number; + error?: string; + at: number; +} + +export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; + +/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */ +export type DiscordStatus = Omit<DiscordConfig, 'token'> & { + hasToken: boolean; + tokenMasked: string; + gateway: DiscordGatewayState; + gatewayError: string | null; + lastResult: DiscordResult | null; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 1af349266..08e30f7f5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,7 @@ * | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE | * | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` | * | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` | + * | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` | * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` | @@ -65,6 +66,7 @@ export * from './run-summary.js'; export * from './tools.js'; export * from './teams.js'; export * from './push.js'; +export * from './discord.js'; export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 6a735f5b8..dcd2d95f1 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js'; export interface SessionPort { readonly sessions: ReadonlyMap<string, Session>; + /** + * The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the + * session routes register, so other server code reads the same per-CLI transcript the viewer does. + */ + lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>; /** * Register a created session with the owner's tab layout. `placement.tabGroupId` * puts it at the end of that group in the same layout write (ignored when the diff --git a/src/web/public/index.html b/src/web/public/index.html index 6611e6c32..cb0ae2e6d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3234,6 +3234,104 @@ <h2>Notifications</h2> <div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div> </div> </div> + <div class="set-group" id="discordGroup" style="display:none"> + <div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div> + <div class="set-group-body"> + <div class="set-row" data-search="discord bot thread reply phone notification blocked"> + <div class="set-row-text"> + <span class="set-row-label">Talk to your sessions from Discord</span> + <span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label> + </div> + <div class="set-row has-field"> + <div class="set-row-text"> + <span class="set-row-label">Bot token</span> + <span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span> + </div> + <input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div> + <input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div> + <input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div> + <input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false"> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Create threads</span></div> + <select id="discordThreadCreation" class="set-select"> + <option value="lazy">On the first alert</option> + <option value="onSessionStart">For every new tab</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div> + <select id="discordOnSessionDelete" class="set-select"> + <option value="keep">Keep the thread</option> + <option value="archive">Archive the thread</option> + <option value="delete">Delete the thread</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. Threads under an hour old are skipped. Use one channel per Codeman.</span></div> + <select id="discordStaleThreads" class="set-select"> + <option value="keep">Leave them</option> + <option value="archive">Archive them</option> + <option value="delete">Delete them</option> + </select> + </div> + <div class="set-row has-field"> + <div class="set-row-text"><span class="set-row-label">@ ping</span></div> + <select id="discordPingMode" class="set-select"> + <option value="off">Never</option> + <option value="critical">Critical events only</option> + <option value="all">Every post</option> + </select> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Tell Claude how to reach you</span> + <span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Also tell other CLIs, as a first message</span> + <span class="set-row-desc">Sends the same note once as a message to a brand-new Codex, OpenCode or similar tab when it first becomes idle. Costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt. Never sent to shell, restored, remote or Docker tabs. Off by default.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="discordTypedHint"><span class="slider"></span></label> + </div> + <div class="set-row has-field"> + <div class="set-row-text"> + <span class="set-row-label">Which events</span> + <span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span> + </div> + <select id="discordScope" class="set-select"> + <option value="attention">Needs attention</option> + <option value="all">Everything</option> + </select> + </div> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Save and test</span> + <span class="set-row-desc">Send test saves first, then posts to the parent channel.</span> + </div> + <span> + <button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button> + <button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button> + <button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button> + </span> + </div> + <div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div> + </div> + </div> </section> <!-- ══ Voice ════════════════════════════════════════════════════ --> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0bf5025c0..1c2795455 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, { this.loadConfigBackups(); this._applyDoctorAdminGate(); this.loadWebhook(); + this.loadDiscord(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -1733,6 +1734,117 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a + * settings-payload field: the bot token is a secret, so it never round-trips through settings.json + * or this page. The token box is write-only. Unlike the webhook group it has its own Save button + * only (the modal's main Save does not touch it), so a half-typed token is never sent by accident. + */ + _discordSay(text, bad = false) { + const out = document.getElementById('discordResult'); + if (!out) return; + out.textContent = text; + out.style.display = text ? 'block' : 'none'; + out.style.color = bad ? 'var(--danger, #e5534b)' : ''; + }, + + async loadDiscord() { + const group = document.getElementById('discordGroup'); + if (!group) return; + const res = await this._api('/api/discord'); + let body = null; + try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ } + if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server + const d = body.data; + group.style.display = ''; + document.getElementById('discordEnabled').checked = d.enabled === true; + const token = document.getElementById('discordToken'); + token.value = ''; + token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal'; + document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.'; + document.getElementById('discordGuildId').value = d.guildId || ''; + document.getElementById('discordParentChannelId').value = d.parentChannelId || ''; + document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', '); + document.getElementById('discordThreadCreation').value = d.threadCreation; + document.getElementById('discordOnSessionDelete').value = d.onSessionDelete; + document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep'; + document.getElementById('discordPingMode').value = d.pingMode; + document.getElementById('discordScope').value = d.scope; + document.getElementById('discordStartupHint').checked = d.startupHint !== false; + document.getElementById('discordTypedHint').checked = d.typedHint === true; + const clearBtn = document.getElementById('discordClearBtn'); + if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none'; + const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` }; + let line = gw[d.gateway] || ''; + if (d.lastResult) { + const when = new Date(d.lastResult.at).toLocaleString(); + line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`; + } + this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok)); + }, + + /** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */ + async saveDiscord() { + const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean); + const payload = { + enabled: document.getElementById('discordEnabled').checked, + guildId: document.getElementById('discordGuildId').value.trim(), + parentChannelId: document.getElementById('discordParentChannelId').value.trim(), + allowedUserIds: ids, + threadCreation: document.getElementById('discordThreadCreation').value, + onSessionDelete: document.getElementById('discordOnSessionDelete').value, + staleThreads: document.getElementById('discordStaleThreads').value, + pingMode: document.getElementById('discordPingMode').value, + scope: document.getElementById('discordScope').value, + startupHint: document.getElementById('discordStartupHint').checked, + typedHint: document.getElementById('discordTypedHint').checked, + }; + const token = document.getElementById('discordToken').value.trim(); + if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it) + const res = await this._api('/api/discord', { method: 'PUT', body: payload }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + const error = body?.error || 'Could not save the Discord settings.'; + this._discordSay(error, true); + return error; + } + await this.loadDiscord(); + return ''; + }, + + async clearDiscord() { + if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return; + const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } }); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not remove the token.', true); + return; + } + await this.loadDiscord(); + }, + + async testDiscord() { + const btn = document.getElementById('discordTestBtn'); + if (btn) btn.disabled = true; + try { + if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown + this._discordSay('Sending…'); + const res = await this._apiPost('/api/discord/test', {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this._discordSay(body?.error || 'Could not send the test.', true); + return; + } + const r = body.data; + this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok); + } finally { + if (btn) btn.disabled = false; + } + }, + /** * Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list * each tool. Built with DOM nodes and textContent: paths and versions come from the host. diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index a59d852d4..1eadcfbb1 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -150,7 +150,12 @@ export async function runConfigBackup( }); } -export function registerConfigBackupRoutes(app: FastifyInstance): void { +export interface ConfigBackupRouteDeps { + /** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */ + reloadDiscord?: () => Promise<void>; +} + +export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void { const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => { if (isMultiUserMode() && !isAdmin(req)) { reply.code(403); @@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (restored.includes('intents.json')) intentStore.reload(); // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). if (restored.includes('clis.json')) reloadCliRegistry(); + // The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge. + if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined); // A restore of users.json replaces accounts, password hashes, roles and bypass grants. if (isMultiUserMode()) { void appendAdminAudit({ diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts new file mode 100644 index 000000000..f77df9e13 --- /dev/null +++ b/src/web/routes/discord-routes.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts). + * + * GET /api/discord the config WITHOUT its token, plus gateway state and last result + * PUT /api/discord change any field; an empty `token` clears it + * POST /api/discord/test post one test message to the parent channel + * POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch) + * + * The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In + * multi-user mode the three settings routes are admin only: the server receives every session's + * events. `discord-notify` follows the session's own ownership rules instead. + */ + +import { readFile, realpath, stat } from 'node:fs/promises'; +import { basename, isAbsolute, relative, sep } from 'node:path'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js'; +import type { SessionPort } from '../ports/session-port.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js'; +import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js'; +import { + discordConfigProblem, + MAX_FILE_BYTES, + maskToken, + readDiscordConfig, + writeDiscordConfig, + type DiscordBridge, +} from '../../discord-bridge.js'; +import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js'; + +export interface DiscordRouteDeps { + bridge: DiscordBridge; + configDir: string; + ctx: SessionPort; + hostTitle: () => string; +} + +/** + * 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, req: FastifyRequest, id: string): string { + if (ctx.sessions.has(id) || id.length < 8) return id; + // Only sessions the caller may see count, so another user's session is never revealed by its prefix. + const user = getAuthUser(req); + const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner)); + return hits.length === 1 ? hits[0].id : id; +} + +// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB +// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's). +const NOTIFY_PER_MINUTE = 6; +const notifyBuckets = new Map<string, { tokens: number; at: number }>(); + +function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean { + if (notifyBuckets.size > 1000) { + for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k); + } + const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now }; + b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000); + b.at = now; + notifyBuckets.set(sessionId, b); + if (b.tokens < 1) return false; + b.tokens -= 1; + return true; +} + +/** Give back a token spent on a request that was refused before anything was posted. */ +function refundNotifyToken(sessionId: string): void { + const b = notifyBuckets.get(sessionId); + if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1); +} + +/** Test hook: forget every bucket between runs. */ +export function _resetNotifyBuckets(): void { + notifyBuckets.clear(); +} + +/** + * 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 + * talked into posting ~/.codeman/discord.json or an ssh key. Returns the bytes, or the reason. + */ +async function fileProblem( + workingDir: string, + remote: boolean, + file: string +): Promise<{ name: string; data: Uint8Array } | string> { + if (remote) return 'Files can only be sent from a local session'; + if (!isAbsolute(file)) return 'The file path must be absolute'; + // Bounded first: realpath on a dead mount would hang the request. + if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') { + return describeUnknownPath('The session folder', workingDir, { pastCap: true }); + } + // The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount. + const kind = await probePathKind(file, { pastCap: true }); + if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true }); + if (kind !== 'file') return 'Not a regular file'; + try { + const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]); + const rel = relative(root, real); + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) + return 'The file must be inside the session folder'; + const info = await stat(real); + if (!info.isFile()) return 'Not a regular file'; + if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`; + return { name: basename(real), data: new Uint8Array(await readFile(real)) }; + } catch { + return 'The file could not be read'; + } +} + +export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void { + const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + const status = async (): Promise<DiscordStatus> => { + const { token, ...rest } = await readDiscordConfig(deps.configDir); + return { + ...rest, + hasToken: token !== '', + tokenMasked: maskToken(token), + gateway: deps.bridge.gatewayState, + gatewayError: deps.bridge.gatewayError, + lastResult: deps.bridge.lastResult, + }; + }; + + app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => { + const no = denied(req, reply); + if (no) return no; + return { success: true, data: await status() }; + }); + + app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => { + const no = denied(req, reply); + if (no) return no; + const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings'); + const current = await readDiscordConfig(deps.configDir); + const next: DiscordConfig = { + ...current, + ...patch, + token: patch.token !== undefined ? patch.token.trim() : current.token, + }; + const problem = discordConfigProblem(next); + if (problem) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + try { + await writeDiscordConfig(deps.configDir, next); + await deps.bridge.reload(); + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + return { success: true, data: await status() }; + }); + + app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => { + const no = denied(req, reply); + if (no) return no; + const cfg = await readDiscordConfig(deps.configDir); + if (!cfg.token || !cfg.parentChannelId) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first'); + } + // 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did. + return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) }; + }); + + app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => { + const { id: rawId } = req.params as { id: string }; + const id = resolveSessionId(deps.ctx, req, rawId); + const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); + const session = findSessionOrFail(deps.ctx, id, req); + if (!consumeNotifyToken(session.id)) { + reply.code(429); + reply.header('Retry-After', '60'); + return createErrorResponse( + ApiErrorCode.RATE_LIMITED, + `Too many messages (${NOTIFY_PER_MINUTE} per minute per session)` + ); + } + let upload: { name: string; data: Uint8Array } | undefined; + if (file !== undefined) { + const problem = await fileProblem(session.workingDir, !!session.remote, file); + if (typeof problem === 'string') { + refundNotifyToken(session.id); + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + upload = problem; + } + const result = await deps.bridge.notify( + { + event: 'session:discord-notify', + title: urgent ? 'Needs you now' : 'Message from the CLI', + body: message, + urgency: urgent ? 'critical' : 'warning', + sessionId: session.id, + sessionName: session.name, + host: deps.hostTitle(), + file: upload, + }, + // deliberate: an agent's own message is never deduped, so a second, different post is not lost. + { urgent: urgent === true, deliberate: true } + ); + if (result === null) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled'); + } + return { success: true, data: result }; + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ed8873068..2516c656e 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -35,6 +35,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js'; export { registerWebhookRoutes } from './webhook-routes.js'; +export { registerDiscordRoutes } from './discord-routes.js'; export { registerDoctorRoutes } from './doctor-routes.js'; export { registerCustomModelRoutes, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0eb80b821..0ada223ab 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2874,16 +2874,14 @@ export function registerSessionRoutes( return candidates[0] ?? null; } - app.get('/api/sessions/:id/last-response', async (req) => { - const { id } = req.params as { id: string }; - const session = findSessionOrFail(ctx, id, req); - + // The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript + // the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic. + const lastResponseFor = async (session: Session, context?: string) => { // Codex sessions don't write to ~/.claude/projects — their transcripts // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the // response-viewer works for Codex panes too. if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') { - const codexQuery = req.query as { context?: string }; - return await readCodexLastResponse(session, codexQuery.context === 'full'); + return await readCodexLastResponse(session, context === 'full'); } // DeepSeek Harness writes a real structured transcript under @@ -2902,8 +2900,7 @@ export function registerSessionRoutes( // would starve on an answer that exists. Those configurations keep the // pane segmenter below: coarse, but the real conversation. if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) { - const deepSeekQuery = req.query as { context?: string }; - const full = deepSeekQuery.context === 'full'; + const full = context === 'full'; const transcript = await readDeepSeekLastResponse(session, { blocks: full }); if (transcript) { return { @@ -2921,7 +2918,6 @@ export function registerSessionRoutes( // the pane IS the transcript for these CLIs. Codex is already handled above, // where a real rollout file is the better source. if (isExternalCliTranscriptMode(session.mode)) { - const externalQuery = req.query as { context?: string }; // A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the // buffer cannot be segmented; read the pane's visible screen instead, and fall back to the // buffer when there is no tmux pane to ask (direct PTY, tests). @@ -2934,7 +2930,7 @@ export function registerSessionRoutes( text: getLastTranscriptResponse(blocks), timestamp: '', hasContext: blocks.length > 0, - messages: externalQuery.context === 'full' ? blocks : undefined, + messages: context === 'full' ? blocks : undefined, }; } @@ -2960,11 +2956,10 @@ export function registerSessionRoutes( } } - const query = req.query as { context?: string }; // `turn` is the brief view's context: the last ANSWERED turn's assistant // messages, so a multi-row answer is not reduced to its final row. `text` // stays the last assistant row in every mode (agent pollers hash it). - const wantsMessages = query.context === 'full' || query.context === 'turn'; + const wantsMessages = context === 'full' || context === 'turn'; const claudeSessionId = session.claudeSessionId || session.id; const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id); if (!transcript) { @@ -2985,7 +2980,7 @@ export function registerSessionRoutes( try { const content = await fs.readFile(transcript.path, 'utf8'); const parsed = parseClaudeResponseTranscript(content, wantsMessages); - if (query.context === 'turn') { + if (context === 'turn') { return { text: parsed.text, timestamp: parsed.timestamp, @@ -2996,6 +2991,13 @@ export function registerSessionRoutes( } catch { return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' }; } + }; + ctx.lastResponseFor = lastResponseFor; + + app.get('/api/sessions/:id/last-response', async (req) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id, req); + return lastResponseFor(session, (req.query as { context?: string }).context); }); function isCodexInjectedContext(text: string): boolean { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6c1ce4746..e97b1169b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -20,6 +20,7 @@ import { import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { CODEX_REASONING_EFFORTS } from '../types/session.js'; import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js'; +import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; @@ -2214,6 +2215,38 @@ export const WebhookUpdateSchema = z }) .strict(); +/** + * PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the + * token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot + * accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route. + */ +export const DiscordUpdateSchema = z + .object({ + enabled: z.boolean().optional(), + token: z.string().max(200).optional(), + guildId: z.string().max(25).optional(), + parentChannelId: z.string().max(25).optional(), + allowedUserIds: z.array(z.string().max(25)).max(20).optional(), + threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(), + onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(), + staleThreads: z.enum(DISCORD_ON_DELETE).optional(), + pingMode: z.enum(DISCORD_PING_MODES).optional(), + scope: z.enum(WEBHOOK_SCOPES).optional(), + startupHint: z.boolean().optional(), + typedHint: z.boolean().optional(), + }) + .strict(); + +/** POST /api/sessions/:id/discord-notify */ +export const DiscordNotifySchema = z + .object({ + message: z.string().min(1).max(1500), + urgent: z.boolean().optional(), + /** Absolute path of a file inside the session's working folder, uploaded with the post. */ + file: z.string().min(1).max(1024).optional(), + }) + .strict(); + // ========== Ralph Loop ========== /** POST /api/ralph-loop/start */ diff --git a/src/web/server.ts b/src/web/server.ts index ddfc296d2..810fe27f7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,6 +45,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from import { rebootRestoreRegistry } from './reboot-restore-registry.js'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js'; +import { DiscordBridge, screenTail } from '../discord-bridge.js'; +import { isExternalCliTranscriptMode } from './response-viewer-transcript.js'; import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; @@ -238,6 +240,7 @@ import { registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, + registerDiscordRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, @@ -506,6 +509,28 @@ export class WebServer extends EventEmitter { private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); /** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */ private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch); + /** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */ + /** Set by the session routes when they register (see SessionPort). */ + lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>; + private discordBridge = new DiscordBridge({ + configDir: getDataDir(), + fetchImpl: webviewFetch, + sendInput: async (sessionId, text, fromUser) => { + const session = this.sessions.get(sessionId); + if (!session) return false; + // fromUser is true only for a Discord reply (a person typed it); the startup hint is Codeman's own + // text and must not auto-name the tab or count as the person's input. + if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true; + return session.write(text, { fromUser }); + }, + agentExited: (id) => !!this.sessions.get(id)?.paneExit, + awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'), + // Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go. + sessionDir: (sessionId) => { + const session = this.sessions.get(sessionId); + return session && !session.remote ? session.workingDir : undefined; + }, + }); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private readonly titleHostname: string; @@ -1312,6 +1337,8 @@ export class WebServer extends EventEmitter { // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); + // The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts). + this.lastResponseFor = (ctx as { lastResponseFor?: WebServer['lastResponseFor'] }).lastResponseFor; registerAgentSessionRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); @@ -1324,12 +1351,23 @@ export class WebServer extends EventEmitter { registerWebviewRoutes(this.app, ctx, this.basePath); registerTabLayoutRoutes(this.app, ctx); registerMcpSyncRoutes(this.app); - registerConfigBackupRoutes(this.app); + registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() }); registerWebhookRoutes(this.app, { notifier: this.webhookNotifier, configDir: getDataDir(), hostTitle: () => this.windowTitle, }); + registerDiscordRoutes(this.app, { + bridge: this.discordBridge, + configDir: getDataDir(), + ctx, + hostTitle: () => this.windowTitle, + }); + if (!this.testMode) { + // The gateway waits for restoreMuxSessions(): a reply during restore would be told the tab is gone. + this.discordBridge.hold(); + void this.discordBridge.ensureLoaded().catch(() => undefined); + } registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -1767,7 +1805,11 @@ export class WebServer extends EventEmitter { // detach: mail posted before that is still there when the session is re-attached // (while detached, the routes answer 404, so nothing new arrives). Before // cancelAll, so a reader released by it reads an empty inbox. - if (killMux) agentInbox.drop(sessionId); + if (killMux) { + agentInbox.drop(sessionId); + // A real delete only: a detach (killMux=false) leaves the session running and re-attachable. + void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined); + } sessionWaits.notifySignal(sessionId, 'exit'); sessionWaits.cancelAll(sessionId); approvalInbox.resolveForSession(sessionId, 'session_ended'); @@ -2699,6 +2741,45 @@ export class WebServer extends EventEmitter { // Multi-user: derive an ownership routing hint so an event only reaches the // clients entitled to it (no-op in single-user — hint stays undefined). this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined); + this.bridgeSessionEvent(event, data); + } + + /** + * Whether the startup hint may be typed into this tab: an external CLI (never Claude, which has it as a + * launch flag, and never a shell), running locally. A remote (ssh) or Docker tab has no `codeman` on its + * PATH, so the hint would tell the agent to run a command it does not have. + */ + private isHintableSession(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!session && isExternalCliMode(session.mode) && !session.remote && !session.docker; + } + + /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */ + private bridgeSessionEvent(event: string, data: unknown): void { + // Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the + // session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path. + if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionIdle) { + return; + } + const d = (data ?? {}) as { + id?: string; + name?: string; + createdAt?: number; + session?: { id?: string; name?: string; createdAt?: number }; + }; + const s = d.session ?? d; + if (!s.id) return; + const id = s.id; + const run = + event === SseEvent.SessionIdle + ? // Claude is told at launch; a shell, a remote or a Docker tab must never be typed at. + this.discordBridge.sessionIdle(id, this.isHintableSession(id)) + : event === SseEvent.SessionCreated + ? this.discordBridge + .sessionCreated(id, s.name, s.createdAt) + .then(() => this.discordBridge.sessionRenamed(id, s.name)) + : this.discordBridge.sessionRenamed(id, s.name); + void run.catch(() => undefined); } /** @@ -2934,6 +3015,27 @@ export class WebServer extends EventEmitter { }) .catch(() => undefined); + // Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed, + // so the person reading the thread can see what is being asked without opening the tab. + const dcSession = sessionId ? this.sessions.get(sessionId) : undefined; + // A question or permission dialog lives on the screen, so it keeps the screen tail (its options are + // there); a finished turn posts the CLI's whole last message instead, from its transcript. + const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog; + void this.discordBridge + .notify({ + event, + title: template.title, + body, + urgency: template.urgency as WebhookUrgency, + sessionId: sessionId || undefined, + sessionName: sessionName || undefined, + // A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post. + tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined, + fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined, + host: this.windowTitle, + }) + .catch(() => undefined); + const subscriptions = this.pushStore.getAll(); if (subscriptions.length === 0) return; @@ -3015,6 +3117,34 @@ export class WebServer extends EventEmitter { } } + /** + * What the person would see on the tab right now, for a Discord alert: the rendered tmux screen + * (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and + * reads as garbage. A session with no pane falls back to its plain text output, else nothing. + */ + private discordTail(session: Session, cutComposer: boolean): string { + const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null; + // The composer row and the status lines under it are screen chrome, not what the CLI said. + const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined; + return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph); + } + + /** + * The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane + * segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the + * screen tail stands in whenever the reader finds nothing. + */ + private async discordFullText(session: Session): Promise<{ text: string; version?: string } | undefined> { + if (!this.lastResponseFor) return undefined; + // A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither. + if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) { + return undefined; + } + const r = await this.lastResponseFor(session); + // The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp). + return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') }; + } + /** The notification body for an event (shared by Web Push and the webhook channel). */ private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string { let body = sessionName ? `[${sessionName}]` : ''; @@ -3156,6 +3286,20 @@ export class WebServer extends EventEmitter { if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`); const restored = await this.restoreMuxSessions(); await this.finalizeRestoredState(restored); + void this.discordBridge + .release() + .then(async () => { + // Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and + // with the same keep set: a reboot-restore rebuild reuses the old id. + if (!restored) return; + await this.discordBridge.prune([ + ...this.sessions.keys(), + ...Object.keys(this.store.getSessions()), + ...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId), + ]); + }) + .then(() => this.discordBridge.startStaleSweep()) + .catch(() => undefined); // Instance-scoped reaper: after restore, `docker rm -f` managed containers of // THIS instance whose case is gone from docker-cases.json (best-effort, never @@ -3740,6 +3884,9 @@ export class WebServer extends EventEmitter { } catch (err) { console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); } + // Per-tab bridge state only: a reboot-restore rebuild reuses the old id and stays on offer for a + // retry, so its thread must survive the discard. + this.discordBridge.forgetTab(sessionId); // Any `session:updated` the half-built session emitted before it failed left a // tab on every other open board, and the client's handler is an upsert. this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); @@ -4312,6 +4459,9 @@ export class WebServer extends EventEmitter { // only by the periodic idle sweep, whose interval is disposed just below. closeAllLlamaSwapLogTails(); + // Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps. + this.discordBridge.stop(); + // Dispose all managed timers (intervals + resettable timeouts) this.cleanup.dispose(); diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index a0987815c..1b8e5a840 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -49,6 +49,8 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe stopTranscriptWatcher: () => {}, persistDeb: { cancelKey: () => {} }, sse: { cleanupSessionBatches: () => {} }, + // A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread. + discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} }, broadcast: () => {}, }); return server; diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts new file mode 100644 index 000000000..5127cf54f --- /dev/null +++ b/test/cli-notify.test.ts @@ -0,0 +1,141 @@ +// @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, delivery?: { ok: boolean; error?: string }) { + const post = vi.fn<JsonPost>(async () => ({ status, error, delivery })); + 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('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => { + const r = await runNotify( + { message: 'x' }, + ENV, + '/w', + fake(200, undefined, { ok: false, error: 'The bot token was refused' }) + ); + expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' }); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0); + }); + + it('postJson gives up on a server that never answers, with status 0', async () => { + const server = createServer(() => undefined); + await new Promise<void>((r) => server.listen(0, '127.0.0.1', r)); + try { + const { port } = server.address() as AddressInfo; + const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100); + expect(r.status).toBe(0); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + it('postJson settles when the server sends headers and then stalls the body', async () => { + const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' }); + res.write('{"success":'); + }); + await new Promise<void>((r) => server.listen(0, '127.0.0.1', r)); + try { + const { port } = server.address() as AddressInfo; + expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + 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(429))).err).toMatch(/Too many messages/); + 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<void>((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<void>((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<void>((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-bridge.test.ts b/test/discord-bridge.test.ts new file mode 100644 index 000000000..1b71c354d --- /dev/null +++ b/test/discord-bridge.test.ts @@ -0,0 +1,1387 @@ +// @vitest-environment node +/** + * @fileoverview Discord bridge: pure rules, the 0600 store, the REST client, the gateway state + * machine (fake socket) and the bridge's gates. No network, no real Discord. + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DiscordConfig } from '../src/types/discord.js'; +import { + buildPost, + DISCORD_STARTUP_HINT, + ensureReceivedDir, + isDiscordCdnUrl, + safeAttachmentName, + screenTail, + splitForDiscord, + tablesToCodeBlocks, + DEFAULT_DISCORD_CONFIG, + DiscordApi, + DiscordBridge, + DiscordGateway, + discordConfigPath, + discordConfigProblem, + maskToken, + readDiscordConfig, + readThreadMap, + sanitiseReply, + shouldPing, + threadNameFor, + writeDiscordConfig, + type DiscordFetch, + type DiscordMessage, + type GatewaySocket, +} from '../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const GUILD = '111111111111111111'; +const PARENT = '222222222222222222'; +const ME = '333333333333333333'; +const THREAD = '444444444444444444'; + +const CFG: DiscordConfig = { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: TOKEN, + guildId: GUILD, + parentChannelId: PARENT, + allowedUserIds: [ME], +}; + +const MSG: DiscordMessage = { + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', +}; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-bridge-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('pure rules', () => { + it('sanitiseReply makes one printable line plus Enter', () => { + expect(sanitiseReply('yes')).toBe('yes\r'); + expect(sanitiseReply('line one\nline two\r\nthree')).toBe('line one line two three\r'); + expect(sanitiseReply('a\u001b[31mb\u0000c')).toBe('a[31mbc\r'); + expect(sanitiseReply(' \n\t ')).toBeNull(); + expect(sanitiseReply('x'.repeat(5000))!.length).toBe(1001); + }); + + it('threadNameFor falls back to the id and clips to 100 characters', () => { + expect(threadNameFor('w3-app', 'abcdef123456')).toBe('w3-app'); + expect(threadNameFor('', 'abcdef123456')).toBe('abcdef12'); + expect(threadNameFor('n'.repeat(300), 'x')).toHaveLength(100); + }); + + it('shouldPing follows pingMode', () => { + expect(shouldPing('off', 'critical', true)).toBe(false); + expect(shouldPing('critical', 'critical')).toBe(true); + expect(shouldPing('critical', 'warning')).toBe(false); + expect(shouldPing('critical', 'warning', true)).toBe(true); + expect(shouldPing('all', 'info')).toBe(true); + }); + + it('buildPost pings only allowlisted ids and never parses mentions', () => { + const post = buildPost({ ...MSG, body: '@everyone <@999999999999999999>' }, CFG); + expect(post.content).toContain(`<@${ME}>`); + expect(post.allowed_mentions).toEqual({ parse: [], users: [ME] }); + const quiet = buildPost(MSG, { ...CFG, pingMode: 'off' }); + expect(quiet.content).not.toContain('<@'); + expect(quiet.allowed_mentions.users).toEqual([]); + }); + + it('buildPost fences the output tail and stays under the Discord limit', () => { + const post = buildPost({ ...MSG, tail: 'Press the reset button ``` now\n' + 'x'.repeat(5000) }, CFG); + expect(post.content.length).toBeLessThanOrEqual(1900); + expect(post.content).toContain('Press the reset button'); + expect(post.content).not.toMatch(/button ``` now/); + }); + + it('discordConfigProblem checks ids and demands a complete config to enable', () => { + expect(discordConfigProblem(CFG)).toBeNull(); + expect(discordConfigProblem(DEFAULT_DISCORD_CONFIG)).toBeNull(); + expect(discordConfigProblem({ ...CFG, guildId: 'abc' })).toMatch(/Guild ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: ['x'] })).toMatch(/user ID/); + expect(discordConfigProblem({ ...CFG, allowedUserIds: [] })).toMatch(/at least one/i); + expect(discordConfigProblem({ ...CFG, token: '' })).toMatch(/token/); + expect(discordConfigProblem({ ...CFG, token: 'has space' })).toMatch(/token/); + }); + + it('maskToken never reveals the middle', () => { + expect(maskToken(TOKEN)).not.toContain('secret'); + expect(maskToken('')).toBe(''); + }); +}); + +describe('store', () => { + it('round-trips the config, writes it 0600 and coerces junk to defaults', async () => { + await writeDiscordConfig(dir, CFG); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect(await readDiscordConfig(dir)).toEqual(CFG); + expect(await readDiscordConfig(join(dir, 'missing'))).toEqual(DEFAULT_DISCORD_CONFIG); + }); +}); + +function jsonRes(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +describe('DiscordApi', () => { + it('sends the token only in Authorization and retries once on 429', async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl: DiscordFetch = async (url, init) => { + calls.push({ url: String(url), init }); + return calls.length === 1 ? jsonRes({ retry_after: 0.01 }, 429) : jsonRes({ id: THREAD }); + }; + const api = new DiscordApi( + () => TOKEN, + fetchImpl, + async () => undefined + ); + const out = await api.createThread(PARENT, 'w1-app'); + expect(out).toEqual({ id: THREAD }); + expect(calls).toHaveLength(2); + expect((calls[0].init.headers as Record<string, string>).Authorization).toBe(`Bot ${TOKEN}`); + expect(String(calls[0].init.body)).not.toContain(TOKEN); + }); + + it('turns an HTTP failure into an error that does not contain the token', async () => { + const api = new DiscordApi( + () => TOKEN, + async () => jsonRes({ code: 50001 }, 403) + ); + const err = await api.post(PARENT, { content: 'x', allowed_mentions: { parse: [], users: [] } }).catch((e) => e); + expect(err.status).toBe(403); + expect(String(err.message)).not.toContain(TOKEN); + }); +}); + +class FakeSocket implements GatewaySocket { + sent: Array<{ op: number; d?: Record<string, unknown> | number | null }> = []; + closed: number[] = []; + private h: { message?: (d: { toString(): string }) => void; close?: (c: number) => void } = {}; + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + close(code = 1000): void { + this.closed.push(code); + this.h.close?.(code); + } + on(event: string, cb: (...a: never[]) => void): void { + (this.h as Record<string, unknown>)[event] = cb; + } + recv(frame: unknown): void { + this.h.message?.({ toString: () => JSON.stringify(frame) }); + } + drop(code: number): void { + this.h.close?.(code); + } +} + +describe('DiscordGateway', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function setup() { + const sockets: FakeSocket[] = []; + const urls: string[] = []; + const seen: unknown[] = []; + const gw = new DiscordGateway( + () => TOKEN, + (m) => seen.push(m), + (url) => { + urls.push(url); + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + () => 10 + ); + return { gw, sockets, urls, seen }; + } + + it('identifies with the three intents, heartbeats, and surfaces MESSAGE_CREATE', () => { + const { gw, sockets, seen } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[0].sent[0].op).toBe(2); + expect((sockets[0].sent[0].d as { intents: number }).intents).toBe(1 | 512 | 32768); + sockets[0].recv({ op: 0, s: 1, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + expect(gw.state).toBe('connected'); + vi.advanceTimersByTime(1000); + expect(sockets[0].sent.at(-1)).toEqual({ op: 1, d: 1 }); + sockets[0].recv({ + op: 0, + s: 2, + t: 'MESSAGE_CREATE', + d: { id: 'm', channel_id: THREAD, content: 'hi', author: { id: ME } }, + }); + expect(seen).toHaveLength(1); + gw.stop(); + }); + + it('resumes on the resume url after a drop', () => { + const { gw, sockets, urls } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + sockets[0].recv({ op: 0, s: 5, t: 'READY', d: { session_id: 'sess', resume_gateway_url: 'wss://resume.example' } }); + sockets[0].drop(1006); + vi.advanceTimersByTime(10); + expect(urls[1]).toContain('wss://resume.example'); + sockets[1].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(sockets[1].sent[0]).toMatchObject({ op: 6, d: { session_id: 'sess', seq: 5 } }); + gw.stop(); + }); + + it('ignores a frame that arrives after stop(): no heartbeat timer, nothing sent', () => { + const { gw, sockets } = setup(); + gw.start(); + gw.stop(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(vi.getTimerCount()).toBe(0); + expect(sockets[0].sent).toHaveLength(0); + }); + + it('stops for good on a close code that retrying cannot fix', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].drop(4014); + vi.advanceTimersByTime(60_000); + expect(sockets).toHaveLength(1); + expect(gw.state).toBe('error'); + expect(gw.lastError).toMatch(/Message Content Intent/); + expect(gw.lastError).not.toContain(TOKEN); + }); + + it('closes a connection that stopped acknowledging heartbeats', () => { + const { gw, sockets } = setup(); + gw.start(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + vi.advanceTimersByTime(1000); // beat 1, no ack + vi.advanceTimersByTime(1000); // beat 2 sees no ack + expect(sockets[0].closed).toContain(4000); + gw.stop(); + }); +}); + +describe('DiscordBridge', () => { + let posts: Array<{ path: string; method: string; body: Record<string, unknown> | undefined }>; + let sendInput: ReturnType<typeof vi.fn<(id: string, text: string, fromUser: boolean) => Promise<boolean>>>; + let t: number; + + function bridge(overrides: Partial<DiscordConfig> = {}, fetchOverride?: DiscordFetch) { + const fetchImpl: DiscordFetch = + fetchOverride ?? + (async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + return init.method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); + }); + return new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + } + + beforeEach(async () => { + posts = []; + t = 1_000_000; + sendInput = vi.fn(async () => true); + await writeDiscordConfig(dir, CFG); + }); + + it('creates the thread lazily on the first alert, reuses it, and persists the map', async () => { + const b = bridge(); + await b.notify(MSG); + t += 10_000; + await b.notify({ ...MSG, event: 'hook:idle_prompt', urgency: 'warning' }); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + expect(posts.filter((p) => p.path === `/channels/${THREAD}/messages`)).toHaveLength(2); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + b.stop(); + }); + + it('makes one thread when two alerts race', async () => { + const b = bridge(); + await Promise.all([b.notify(MSG), b.notify({ ...MSG, event: 'hook:elicitation_dialog' })]); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + b.stop(); + }); + + it('dedupes an automatic event within 3 s and says so, which is not the "bridge is off" answer', async () => { + const b = bridge(); + expect((await b.notify(MSG))?.ok).toBe(true); + const second = await b.notify(MSG); + expect(second).toMatchObject({ ok: true, deduped: true }); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(1); + b.stop(); + }); + + it('never dedupes a deliberate post: two different messages from one tab within 3 s both go out', async () => { + const b = bridge(); + const a = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'first' }, { deliberate: true }); + const c = await b.notify({ ...MSG, event: 'session:discord-notify', body: 'second' }, { deliberate: true }); + expect(a?.deduped).toBeUndefined(); + expect(c?.deduped).toBeUndefined(); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(2); + b.stop(); + }); + + it('builds the alert tail only for a post that goes out (not while off, not when deduped)', async () => { + const tail = vi.fn(() => 'screen text'); + const b = bridge(); + await b.notify({ ...MSG, tail }); + expect(tail).toHaveBeenCalledTimes(1); + await b.notify({ ...MSG, tail }); // deduped + expect(tail).toHaveBeenCalledTimes(1); + expect(posts.at(-1)?.body?.content).toContain('screen text'); + await writeDiscordConfig(dir, { ...CFG, enabled: false }); + await b.reload(); + await b.notify({ ...MSG, event: 'hook:idle_prompt', tail }); // off + expect(tail).toHaveBeenCalledTimes(1); + b.stop(); + }); + + it('survives a tail that throws', async () => { + const b = bridge(); + const r = await b.notify({ + ...MSG, + tail: () => { + throw new Error('tmux gone'); + }, + }); + expect(r?.ok).toBe(true); + b.stop(); + }); + + it('writes the thread map one write at a time, so threads made at once all survive a restart', async () => { + let n = 0; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + if (path.endsWith('/threads')) return jsonRes({ id: String(500000000000000000n + BigInt(++n)) }); + return jsonRes({ id: 'msg' }); + }); + await Promise.all(['a1', 'a2', 'a3', 'a4'].map((id) => b.notify({ ...MSG, sessionId: id, event: `e-${id}` }))); + const map = await readThreadMap(dir); + expect(Object.keys(map).sort()).toEqual(['a1', 'a2', 'a3', 'a4']); + b.stop(); + }); + + it('skips info events under scope attention and everything while disabled', async () => { + const b = bridge(); + expect(await b.notify({ ...MSG, urgency: 'info' })).toBeNull(); + await writeDiscordConfig(dir, { ...CFG, enabled: false }); + await b.reload(); + expect(await b.notify(MSG)).toBeNull(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('recreates a thread that was deleted by hand', async () => { + let first = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (first && path.endsWith('/messages')) { + first = false; + return jsonRes({ code: 10003 }, 404); + } + return jsonRes({ id: 'msg' }); + }); + const r = await b.notify(MSG); + expect(r?.ok).toBe(true); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(2); + b.stop(); + }); + + it('reports a failure without the token and never throws', async () => { + const b = bridge({}, async () => { + throw new TypeError('fetch failed'); + }); + const r = await b.notify(MSG); + expect(r).toMatchObject({ ok: false, error: 'Network error' }); + expect(JSON.stringify(r)).not.toContain(TOKEN); + b.stop(); + }); + + describe('replies', () => { + const reply = (over: Record<string, unknown> = {}) => ({ + id: 'm1', + channel_id: THREAD, + guild_id: GUILD, + content: 'press y', + author: { id: ME }, + ...over, + }); + + async function ready() { + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + t += 10_000; + return b; + } + + it('types an allowed reply into the mapped session and ticks it', async () => { + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(sendInput).toHaveBeenCalledWith('s1', 'press y\r', true); // a person typed it + expect(posts[0].method).toBe('PUT'); + expect(posts[0].path).toContain('/reactions/'); + b.stop(); + }); + + it.each([ + ['a stranger', { author: { id: '999999999999999999' } }], + ['a bot', { author: { id: ME, bot: true } }], + ['another guild', { guild_id: '555555555555555555' }], + ['an unmapped channel', { channel_id: '666666666666666666' }], + ['an empty message', { content: ' ' }], + ])('drops %s without typing anything', async (_n, over) => { + const b = await ready(); + await b.onGatewayMessage(reply(over)); + expect(sendInput).not.toHaveBeenCalled(); + expect(posts).toHaveLength(0); + b.stop(); + }); + + it('turns a multi-line reply into one line and rate limits a burst', async () => { + const b = await ready(); + await b.onGatewayMessage(reply({ content: 'a\nb' })); + await b.onGatewayMessage(reply({ content: 'again' })); + expect(sendInput).toHaveBeenCalledTimes(1); + expect(sendInput).toHaveBeenCalledWith('s1', 'a b\r', true); + // The second reply was not typed, and the person is told so instead of it vanishing. + expect(posts.some((p) => p.method === 'PUT' && p.path.includes(encodeURIComponent('⏳')))).toBe(true); + b.stop(); + }); + + it('says so in the thread when the tab is gone', async () => { + sendInput.mockResolvedValueOnce(false); + const b = await ready(); + await b.onGatewayMessage(reply()); + expect(posts[0].method).toBe('POST'); + expect(String(posts[0].body?.content)).toMatch(/gone/); + b.stop(); + }); + }); + + it('starts afresh when the parent channel changes: tabs get a thread in the new channel', async () => { + const b = bridge(); + await b.notify(MSG); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + posts.length = 0; + await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); + await b.reload(); + await b.notify({ ...MSG, event: 'hook:idle_prompt' }); + expect(posts.find((p) => p.path.endsWith('/threads'))?.path).toBe('/channels/777777777777777777/threads'); + b.stop(); + }); + + it('keeps the threads across a restart under the same parent channel', async () => { + const first = bridge(); + await first.notify(MSG); + first.stop(); + posts.length = 0; + const second = bridge(); + await second.reload(); + await second.notify({ ...MSG, event: 'hook:idle_prompt' }); + expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(false); + second.stop(); + }); + + it('a failed thread creation reports its own failure even if another post succeeds meanwhile', async () => { + let n = 0; + const b = bridge({}, async (url) => { + if (String(url).endsWith('/threads')) { + await new Promise((r) => setTimeout(r, 20)); + return new Response('{}', { status: 403 }); + } + n++; + return jsonRes({ id: 'msg' }); + }); + const [failed, ok] = await Promise.all([ + b.notify(MSG), + b.notify({ ...MSG, sessionId: undefined, event: 'session:error' }), + ]); + expect(n).toBeGreaterThan(0); + expect(ok?.ok).toBe(true); + expect(failed).toMatchObject({ ok: false, status: 403 }); + b.stop(); + }); + + it('drops a thread whose creation finished after the parent channel changed', async () => { + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const b = bridge({}, async (url, init) => { + if (String(url).endsWith(`/channels/${PARENT}/threads`)) { + await gate; + return jsonRes({ id: THREAD }); + } + posts.push({ + path: new URL(String(url)).pathname.replace('/api/v10', ''), + method: String(init.method), + body: init.body ? JSON.parse(String(init.body)) : undefined, + }); + return jsonRes({ id: 'msg' }); + }); + const first = b.notify(MSG); + await new Promise((r) => setTimeout(r, 10)); + await writeDiscordConfig(dir, { ...CFG, parentChannelId: '777777777777777777' }); + await b.reload(); + release(); + expect(await first).toMatchObject({ ok: false }); + // The thread made in the old channel is tidied, not left open there. + expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + describe('full last message', () => { + const LONG = Array.from({ length: 60 }, (_, i) => `**Point ${i}**: ${'detail '.repeat(10)}\nIs this right?`).join( + '\n\n' + ); + + it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => { + const b = bridge(); + await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => ({ text: LONG }) }); + const sent = posts.filter((p) => p.path.endsWith('/messages')); + expect(sent.length).toBeGreaterThan(1); + const first = String(sent[0].body?.content); + expect(first).toContain(MSG.title); + expect(first).toContain('**Point 0**'); + expect(first).not.toContain('screen chrome'); + expect(first).not.toContain('```'); // markdown, not a code box + expect(sent[1].body?.allowed_mentions).toEqual({ parse: [], users: [] }); + const all = sent.map((p) => String(p.body?.content)).join('\n'); + expect(all.match(/Is this right\?/g)).toHaveLength(60); + b.stop(); + }); + + it('posts a Markdown table as an aligned code block, since Discord does not render tables', async () => { + const b = bridge(); + const table = [ + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| kv-f16 | 557.0 |', + '| n-gram mod | 550.2 |', + ].join('\n'); + await b.notify({ ...MSG, fullText: async () => ({ text: `Results:\n\n${table}` }) }); + const posted = posts + .filter((p) => p.path.endsWith('/messages')) + .map((p) => String(p.body?.content)) + .join('\n'); + expect(posted).toContain('```\nVariant Decode (tokens/s)\n'); + expect(posted).not.toContain('|---'); + b.stop(); + }); + + it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => { + const b = bridge(); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Short answer.' }) }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => ({ text: 'Short answer.' }) }); + const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content)); + expect(again).toHaveLength(1); + expect(again[0]).not.toContain('Short answer.'); + expect(again[0]).not.toContain('chrome'); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:idle_prompt', tail: 'the screen', fullText: async () => undefined }); + expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); + b.stop(); + }); + + it('leaves a message to be posted again when its first post failed', async () => { + let fail = true; + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + if (fail && path.endsWith('/messages')) return new Response('{}', { status: 500 }); + return jsonRes({ id: 'msg' }); + }); + const first = await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'The answer.' }) }); + expect(first).toMatchObject({ ok: false }); + fail = false; + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:idle_prompt', fullText: async () => ({ text: 'The answer.' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('The answer.'); + b.stop(); + }); + + it('posts a later turn that ends on the same words, but not the same turn twice', async () => { + const b = bridge(); + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', fullText: async () => ({ text: 'Done.', version: 'turn-1' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).not.toContain('Done.'); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, fullText: async () => ({ text: 'Done.', version: 'turn-2' }) }); + expect(posts.map((p) => String(p.body?.content)).join('\n')).toContain('Done.'); + b.stop(); + }); + + it('falls back to the tail when reading the transcript throws or hangs', async () => { + const b = bridge(); + await b.notify({ + ...MSG, + tail: 'the screen', + fullText: async () => { + throw new Error('no transcript'); + }, + }); + expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen'); + b.stop(); + }); + }); + + describe('sweepStaleThreads', () => { + const ME = '555555555555555555'; + const OTHER = '666666666666666666'; + const OLD = (n: number) => `30000000000000000${n}`; // a snowflake from 2015, long before `now` + let young: string; + let calls: Array<{ method: string; path: string; body?: unknown }>; + + async function swept(staleThreads: 'keep' | 'archive' | 'delete') { + await writeDiscordConfig(dir, { ...CFG, staleThreads }); + t = Date.now(); + young = String((BigInt(t - 60_000 - 1420070400000) << 22n) + 5n); + calls = []; + const th = (id: string, owner = ME, parent = PARENT) => ({ id, owner_id: owner, parent_id: parent }); + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + const method = String(init.method); + calls.push({ method, path, body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path === '/users/@me') return jsonRes({ id: ME }); + if (path.endsWith('/threads/active')) + return jsonRes({ + threads: [ + th(THREAD), // mapped + th(OLD(1)), // stale + th(OLD(2), OTHER), // someone else's thread + th(OLD(3), ME, '999999999999999999'), // another channel + th(young), // too new to judge + ], + }); + if (path.includes('/threads/archived')) return jsonRes({ threads: [th(OLD(4))] }); + if (path.endsWith('/threads')) return jsonRes({ id: THREAD }); + return method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' }); + }); + return b.notify(MSG).then(() => { + calls.length = 0; + return b; + }); + } + const touched = () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path.split('/').pop()}`); + + it('does nothing while set to keep', async () => { + const b = await swept('keep'); + expect(await b.sweepStaleThreads()).toBe(0); + expect(calls).toHaveLength(0); + b.stop(); + }); + + it("archives only this bot's old, unmapped threads in the parent channel, and skips archived ones", async () => { + const b = await swept('archive'); + expect(await b.sweepStaleThreads()).toBe(1); + expect(touched()).toEqual([`PATCH ${OLD(1)}`]); + expect(calls.some((c) => c.path.includes('/threads/archived'))).toBe(false); + b.stop(); + }); + + it('deletes stale active and archived threads, and still spares the rest', async () => { + const b = await swept('delete'); + expect(await b.sweepStaleThreads()).toBe(2); + expect(touched()).toEqual([`DELETE ${OLD(1)}`, `DELETE ${OLD(4)}`]); + b.stop(); + }); + + it('does nothing when the map is empty, since a lost map would make every thread look stale', async () => { + const b = await swept('delete'); + await b.sessionDeleted('s1'); // empties the map + calls.length = 0; + expect(await b.sweepStaleThreads()).toBe(0); + expect(calls).toHaveLength(0); + b.stop(); + }); + }); + + it('keeps the gateway off while held (boot) and starts it on release', async () => { + const b = bridge(); + b.hold(); + await b.reload(); + expect(b.gatewayState).toBe('off'); + await b.release(); + expect(b.gatewayState).not.toBe('off'); + b.stop(); + }); + + it('prune drops thread entries for tabs that no longer exist, and keeps the live ones', async () => { + const b = bridge(); + await b.notify(MSG); + await b.notify({ ...MSG, sessionId: 's2', sessionName: 'w2' }); + await b.prune(['s2']); + expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); + await b.prune([]); // nothing restored: leave the map alone + expect(Object.keys(await readThreadMap(dir))).toEqual(['s2']); + b.stop(); + }); + + describe('sendTest', () => { + it('posts with the config it is given and leaves the live config alone', async () => { + const auth: string[] = []; + const b = bridge({}, async (_url, init) => { + auth.push(String((init.headers as Record<string, string>).Authorization)); + return jsonRes({ id: 'msg' }); + }); + await b.reload(); + expect((await b.sendTest({ ...CFG, token: 'test-token' }))?.ok).toBe(true); + // A save that landed during the test must not be undone: the live token is still the saved one. + await b.notify(MSG); + expect(auth[0]).toBe('Bot test-token'); + expect(auth.slice(1).every((a) => a === `Bot ${CFG.token}`)).toBe(true); + b.stop(); + }); + }); + + it('names a thread that Discord answers without an id, instead of reporting an old result', async () => { + const b = bridge({}, async (url) => (String(url).endsWith('/threads') ? jsonRes({}) : jsonRes({ id: 'msg' }))); + const r = await b.notify(MSG); + expect(r).toMatchObject({ ok: false, error: 'Discord did not return a thread' }); + b.stop(); + }); + + it('replaces a gateway that gave up on a fatal close when the same config is saved again', async () => { + const sockets: FakeSocket[] = []; + const b = new DiscordBridge({ + configDir: dir, + fetchImpl: async () => jsonRes({ id: 'msg' }), + sendInput, + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + now: () => t, + sleep: async () => undefined, + }); + await b.reload(); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + sockets[0].drop(4014); // Message Content Intent off: fatal, no reconnect + expect(b.gatewayState).toBe('error'); + await b.reload(); + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + expect(b.gatewayState).not.toBe('error'); + b.stop(); + }); + + it('keeps the gateway session across a save that changes neither the switch nor the token', async () => { + const b = bridge(); + await b.reload(); + const first = (b as unknown as { gateway: unknown }).gateway; + expect(first).not.toBeNull(); + await b.reload(); + expect((b as unknown as { gateway: unknown }).gateway).toBe(first); + await writeDiscordConfig(dir, { ...CFG, token: 'another-token' }); + await b.reload(); + expect((b as unknown as { gateway: unknown }).gateway).not.toBe(first); + b.stop(); + }); + + it('does not rename a thread to the name it already has on first sight', async () => { + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionRenamed('s1', 'w1-app'); + expect(posts).toHaveLength(0); + b.stop(); + }); + + describe('per-tab state', () => { + const internals = (b: DiscordBridge) => + b as unknown as { names: Map<string, string>; lastReply: Map<string, number>; fresh: Set<string> }; + + it('a deleted tab that never had a thread still frees its entries', async () => { + const b = bridge(); + await b.reload(); + await b.sessionCreated('nothread', 'w', t - 1000); + await b.sessionRenamed('nothread', 'w'); + internals(b).lastReply.set('nothread', 1); + await b.sessionDeleted('nothread'); + expect(internals(b).names.has('nothread')).toBe(false); + expect(internals(b).lastReply.has('nothread')).toBe(false); + expect(internals(b).fresh.has('nothread')).toBe(false); + b.stop(); + }); + + it('stop() clears the maps and the gateway', async () => { + const b = bridge(); + await b.reload(); + await b.sessionCreated('x', 'w', t - 1000); + await b.sessionRenamed('x', 'w'); + b.stop(); + expect(internals(b).fresh.size).toBe(0); + expect(internals(b).names.size).toBe(0); + expect(b.gatewayState).toBe('off'); + }); + }); + + describe('tab lifecycle', () => { + it('creates the thread at once under onSessionStart, not under lazy', async () => { + const lazy = bridge(); + await lazy.sessionCreated('s1', 'w1-app'); + expect(posts).toHaveLength(0); + lazy.stop(); + await writeDiscordConfig(dir, { ...CFG, threadCreation: 'onSessionStart' }); + const eager = bridge(); + await eager.reload(); + await eager.sessionCreated('s1', 'w1-app'); + expect(posts.filter((p) => p.path.endsWith('/threads'))).toHaveLength(1); + eager.stop(); + }); + + it.each([ + ['archive', 'PATCH', { archived: true }], + ['delete', 'DELETE', undefined], + ] as const)('onSessionDelete %s calls the matching API', async (mode, method, body) => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: mode }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method, body }]); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('keep leaves the thread alone but still forgets the mapping', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'keep' }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + await b.sessionDeleted('s1'); + expect(posts).toHaveLength(0); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('forgetTab drops the per-tab state but keeps the thread and its map entry', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'delete' }); + const b = bridge(); + await b.notify(MSG); + posts.length = 0; + b.forgetTab('s1'); + await b.sessionRenamed('s1', 'other'); // first sight again: recorded, no PATCH + expect(posts).toHaveLength(0); + expect(await readThreadMap(dir)).toEqual({ s1: THREAD }); + b.stop(); + }); + + it('a tab closed while its thread is being created has that thread closed and no entry kept', async () => { + await writeDiscordConfig(dir, { ...CFG, onSessionDelete: 'archive' }); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const b = bridge({}, async (url, init) => { + const path = new URL(String(url)).pathname.replace('/api/v10', ''); + posts.push({ path, method: String(init.method), body: init.body ? JSON.parse(String(init.body)) : undefined }); + if (path.endsWith('/threads')) { + await gate; + return jsonRes({ id: THREAD }); + } + return jsonRes({ id: 'msg' }); + }); + const first = b.notify(MSG); + await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(true)); + await b.sessionDeleted('s1'); + release(); + const out = await first; + expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); + expect(out).toMatchObject({ ok: false, error: 'The tab was closed' }); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + + it('renames the thread only when the name really changed', async () => { + const b = bridge(); + await b.notify(MSG); + await b.sessionRenamed('s1', 'w1-app'); // same name notify() used: first sight records it + posts.length = 0; + await b.sessionRenamed('s1', 'w1-app'); + await b.sessionRenamed('s1', 'fix login'); + expect(posts).toEqual([{ path: `/channels/${THREAD}`, method: 'PATCH', body: { name: 'fix login' } }]); + b.stop(); + }); + }); +}); + +describe('files', () => { + it('safeAttachmentName keeps one safe path segment', () => { + expect(safeAttachmentName('report final (1).pdf')).toBe('report_final_1_.pdf'); + expect(safeAttachmentName('../../etc/passwd')).toBe('passwd'); + expect(safeAttachmentName('.env')).toBe('env'); + expect(safeAttachmentName('')).toBe('file'); + expect(safeAttachmentName('x'.repeat(300))).toHaveLength(80); + }); + + it('isDiscordCdnUrl accepts only Discord https hosts', () => { + expect(isDiscordCdnUrl('https://cdn.discordapp.com/attachments/1/2/a.png')).toBe(true); + expect(isDiscordCdnUrl('https://media.discordapp.net/attachments/1/2/a.png')).toBe(true); + expect(isDiscordCdnUrl('http://cdn.discordapp.com/a.png')).toBe(false); + expect(isDiscordCdnUrl('https://evil.example/a.png')).toBe(false); + expect(isDiscordCdnUrl('https://discordapp.com.evil.example/a.png')).toBe(false); + expect(isDiscordCdnUrl('http://169.254.169.254/')).toBe(false); + expect(isDiscordCdnUrl('nonsense')).toBe(false); + }); + + it('sanitiseReply appends attached paths after the cap, and a file alone is enough', () => { + expect(sanitiseReply('see this', ['/w/.codeman-discord/1-a.png'])).toBe( + 'see this [attached: /w/.codeman-discord/1-a.png]\r' + ); + expect(sanitiseReply('', ['/w/a'])).toBe('[attached: /w/a]\r'); + expect(sanitiseReply('x'.repeat(5000), ['/w/a'])!.endsWith('[attached: /w/a]\r')).toBe(true); + }); + + it('postWithFile sends multipart with a safe filename and no JSON content type', async () => { + let seen: { init: RequestInit } | null = null; + const api = new DiscordApi( + () => TOKEN, + async (_u, init) => { + seen = { init }; + return jsonRes({ id: 'm' }); + } + ); + await api.postWithFile( + PARENT, + { content: 'hi', allowed_mentions: { parse: [], users: [] } }, + { name: '../x y.log', data: new Uint8Array([1, 2, 3]) } + ); + const body = seen!.init.body as FormData; + expect(body).toBeInstanceOf(FormData); + expect(JSON.parse(String(body.get('payload_json'))).content).toBe('hi'); + expect((body.get('files[0]') as File).name).toBe('x_y.log'); + expect((seen!.init.headers as Record<string, string>)['Content-Type']).toBeUndefined(); + }); +}); + +describe('DiscordBridge files and startup hint', () => { + const reply = (over: Record<string, unknown> = {}) => ({ + id: '555555555555555555', + channel_id: THREAD, + guild_id: GUILD, + content: '', + author: { id: ME }, + attachments: [ + { id: 'a1', filename: 'shot one.png', size: 3, url: 'https://cdn.discordapp.com/attachments/1/2/shot.png' }, + ], + ...over, + }); + let work: string; + let sent: Array<[string, string, boolean]>; + let posted: Array<{ path: string; body?: Record<string, unknown> }>; + let downloads: string[]; + + function make( + cfg: Partial<DiscordConfig> = {}, + dirFor: (id: string) => string | undefined = () => work, + awaitingChoice?: (id: string) => boolean, + agentExited?: (id: string) => boolean + ) { + const b = new DiscordBridge({ + configDir: dir, + fetchImpl: async (url, init) => { + const u = new URL(String(url)); + if (u.hostname === 'cdn.discordapp.com') { + downloads.push(String(url)); + return new Response(new Uint8Array([7, 8, 9])); + } + const path = u.pathname.replace('/api/v10', ''); + posted.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined }); + return path.endsWith('/threads') ? jsonRes({ id: THREAD }) : jsonRes({ id: 'msg' }); + }, + sendInput: async (id, text, fromUser) => (sent.push([id, text, fromUser]), true), + sessionDir: dirFor, + awaitingChoice, + agentExited, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + return writeDiscordConfig(dir, { ...CFG, typedHint: true, ...cfg }).then(async () => { + await b.reload(); + await b.notify(MSG); + posted.length = 0; + return b; + }); + } + let t = 5_000_000; + + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-work-')); + sent = []; + posted = []; + downloads = []; + }); + afterEach(() => rmSync(work, { recursive: true, force: true })); + + it('saves an attachment under .codeman-discord and types its path, even with no text', async () => { + const b = await make(); + await b.onGatewayMessage(reply()); + const dest = join(work, '.codeman-discord', '555555555555555555-shot_one.png'); + expect(readFileSync(dest)).toEqual(Buffer.from([7, 8, 9])); + expect(statSync(dest).mode & 0o777).toBe(0o600); + expect(sent).toEqual([['s1', `[attached: ${dest}]\r`, true]]); + b.stop(); + }); + + it('refuses a non-Discord URL, an oversize file and a sixth file, and says so in the thread', async () => { + const b = await make(); + const many = Array.from({ length: 6 }, (_v, i) => ({ + id: String(i), + filename: `f${i}.txt`, + size: 1, + url: 'https://cdn.discordapp.com/a/' + i, + })); + await b.onGatewayMessage( + reply({ + content: 'look', + attachments: [ + { id: 'x', filename: 'evil.png', size: 1, url: 'http://169.254.169.254/latest' }, + { id: 'y', filename: 'huge.bin', size: 9 * 1024 * 1024, url: 'https://cdn.discordapp.com/a/huge' }, + ...many, + ], + }) + ); + expect(downloads.some((u) => u.includes('169.254'))).toBe(false); + expect(downloads.some((u) => u.includes('huge'))).toBe(false); + expect(downloads).toHaveLength(3); // 5 allowed in total, two of which were the refused ones above + expect(posted.map((p) => String(p.body?.content)).find((c) => c.startsWith('Not saved'))).toMatch( + /evil\.png.*huge\.bin/ + ); + b.stop(); + }); + + it('says there is no folder for a remote tab and types nothing for a file-only reply', async () => { + const b = await make({}, () => undefined); + await b.onGatewayMessage(reply()); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/no local folder/); + b.stop(); + }); + + it('sends the startup hint once, only to a fresh external session, and never to a shell', async () => { + const b = await make(); + await b.sessionCreated('s2', 'w2', t - 1000); + await b.sessionIdle('s2', false); // shell or Claude: nothing + expect(sent).toHaveLength(0); + await b.sessionCreated('s3', 'w3', t - 1000); + await b.sessionIdle('s3', true); + await b.sessionIdle('s3', true); // second idle: nothing + expect(sent).toHaveLength(1); + expect(sent[0][0]).toBe('s3'); + expect(sent[0][1]).toContain('codeman notify'); + expect(sent[0][1].endsWith('\r')).toBe(true); + expect(sent[0][2]).toBe(false); // Codeman's own text: not a person's input, so it cannot auto-name the tab + expect(DISCORD_STARTUP_HINT).toContain('codeman notify'); + b.stop(); + }); + + it('holds the startup hint while a choice is pending and sends it at the next idle', async () => { + let choice = true; + const b = await make({}, undefined, () => choice); + await b.sessionCreated('s6', 'w6', t - 1000); + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(0); + choice = false; + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(1); + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(1); + b.stop(); + }); + + it('does not hint a tab that was already past its first idle while the hint was off', async () => { + const off = await make({ typedHint: false }); + await off.sessionCreated('s8', 'w8', t - 1000); + await off.sessionIdle('s8', true); // first idle with the hint off: no longer new + await writeDiscordConfig(dir, { ...CFG, typedHint: true }); + await off.reload(); + await off.sessionIdle('s8', true); + expect(sent).toHaveLength(0); + off.stop(); + }); + + it('says the agent has exited and types nothing when the tab reports it', async () => { + const b = await make({}, undefined, undefined, () => true); + await b.onGatewayMessage(reply({ content: 'hello', attachments: [] })); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/exited/); + expect(posted.some((p) => p.path.includes('reactions'))).toBe(false); + b.stop(); + }); + + it('types a reply that waits on a download before a later text reply', async () => { + const b = await make(); + const slow = b.onGatewayMessage(reply({ content: 'first' })); + await new Promise((r) => setTimeout(r, 0)); // the first reply is under way (gate passed) before the clock moves + t += 5_000; + const fast = b.onGatewayMessage(reply({ id: '555555555555555556', content: 'second', attachments: [] })); + await Promise.all([slow, fast]); + expect(sent.map((s) => s[1].split(' ')[0])).toEqual(['first', 'second\r']); + b.stop(); + }); + + it('types a lone digit without Enter while a dialog is waiting, and with Enter otherwise', async () => { + let waiting = true; + const b = await make({}, undefined, () => waiting); + await b.onGatewayMessage(reply({ content: '2', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('2'); + t += 5_000; + await b.onGatewayMessage(reply({ content: 'yes', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('yes\r'); + t += 5_000; + waiting = false; + await b.onGatewayMessage(reply({ content: '2', attachments: [] })); + expect(sent.at(-1)?.[1]).toBe('2\r'); + b.stop(); + }); + + it('does not save a file for a message id that is not a snowflake, and says so', async () => { + const b = await make(); + await b.onGatewayMessage(reply({ id: '../../x' })); + expect(downloads).toHaveLength(0); + expect(sent).toHaveLength(0); + expect(String(posted.at(-1)?.body?.content)).toMatch(/could not be saved/); + b.stop(); + }); + + it('sends the typed hint only when typedHint is on (the default is off)', async () => { + const off = await make({ typedHint: false }); + await off.sessionCreated('s7', 'new', t - 1000); + await off.sessionIdle('s7', true); + expect(sent).toHaveLength(0); + off.stop(); + expect(DEFAULT_DISCORD_CONFIG.typedHint).toBe(false); + }); + + it('does not hint a restored (old) session, or when startupHint is off', async () => { + const b = await make(); + await b.sessionCreated('s4', 'old', 1); // created long ago: restored at server start + await b.sessionIdle('s4', true); + expect(sent).toHaveLength(0); + b.stop(); + const off = await make({ startupHint: false }); + await off.sessionCreated('s5', 'new', t - 1000); + await off.sessionIdle('s5', true); + expect(sent).toHaveLength(0); + off.stop(); + }); +}); + +describe('tablesToCodeBlocks', () => { + it('turns a Markdown table into an aligned code block, dropping bold and code marks', () => { + const md = [ + 'Results:', + '', + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| `kv-f16` | 557.0 |', + '| **n-gram simple** | 554.5 |', + '| n-gram mod | 550.2 |', + '', + 'Done.', + ].join('\n'); + expect(tablesToCodeBlocks(md)).toBe( + [ + 'Results:', + '', + '```', + 'Variant Decode (tokens/s)', + '------------- -----------------', + 'kv-f16 557.0', + 'n-gram simple 554.5', + 'n-gram mod 550.2', + '```', + '', + 'Done.', + ].join('\n') + ); + }); + + it('leaves a table inside a code fence, prose with pipes and a table-less message alone', () => { + const fenced = ['```', '| a | b |', '|---|---|', '| 1 | 2 |', '```'].join('\n'); + expect(tablesToCodeBlocks(fenced)).toBe(fenced); + const prose = 'Use a | b to pipe.\nThen c | d.'; + expect(tablesToCodeBlocks(prose)).toBe(prose); + }); + + it('converts a hostile separator row of 100,000 spaces in well under a second', () => { + const rows = ['| a | b |', `|---${' '.repeat(100_000)}x`, '| 1 | 2 |'].join('\n'); + const t0 = Date.now(); + expect(tablesToCodeBlocks(rows)).toBe(rows); + expect(Date.now() - t0).toBeLessThan(500); + const real = ['| a | b |', `| --- ${' '.repeat(50_000)}| --- |`, '| 1 | 2 |'].join('\n'); + const t1 = Date.now(); + expect(tablesToCodeBlocks(real)).toContain('```'); + expect(Date.now() - t1).toBeLessThan(500); + }); + + it('pads a short row and keeps an escaped pipe in a cell', () => { + const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| x \\| y |', '| p | q |'].join('\n')); + expect(out).toContain('x | y'); + expect(out.split('\n').every((l) => !/\s$/.test(l))).toBe(true); + }); +}); + +describe('splitForDiscord', () => { + it('keeps a short message whole', () => { + expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']); + }); + + it('splits a long message into posts under the limit and loses no line', () => { + const lines = Array.from({ length: 120 }, (_, i) => `- item ${i} ${'x'.repeat(40)}`); + const posts = splitForDiscord(lines.join('\n')); + expect(posts.length).toBeGreaterThan(1); + expect(posts.every((p) => p.length <= 1900)).toBe(true); + expect( + posts + .join('\n') + .split('\n') + .filter((l) => l.startsWith('- item')) + ).toEqual(lines); + }); + + it('closes a code fence the cut lands inside and reopens it in the next post', () => { + const body = ['```', ...Array.from({ length: 100 }, (_, i) => `row ${i} ${'y'.repeat(40)}`), '```'].join('\n'); + const posts = splitForDiscord(body); + expect(posts.length).toBeGreaterThan(1); + for (const p of posts) expect((p.match(/^```/gm) ?? []).length % 2).toBe(0); + }); + + it('cuts one enormous line by length, and caps the number of posts with a note', () => { + const posts = splitForDiscord('z'.repeat(50_000), 1900, 3); + expect(posts).toHaveLength(3); + expect(posts.every((p) => p.length <= 2000)).toBe(true); // Discord's own limit, with the note included + expect(posts[2]).toMatch(/continues in the tab/); + }); +}); + +describe('screenTail', () => { + it("cuts the composer row and the status lines under it, but keeps a dialog's selected option", () => { + const idle = [ + " I'll sync the results and update the docs when the run finishes.", + '✻ Crunched for 7m 55s · done 4:22 PM', + '✔ Update installed · /restart to apply', + '❯', + ' Sonnet 5.5 in:169,629 out:751 ctx:17% cache:until 17:22', + ' ⏵⏵ bypass permissions on (shift+tab to cycle)', + ].join('\n'); + expect(screenTail(idle, 14, 900, '❯')).toBe( + [ + " I'll sync the results and update the docs when the run finishes.", + '✻ Crunched for 7m 55s · done 4:22 PM', + '✔ Update installed · /restart to apply', + ].join('\n') + ); + const dialog = ['Do you want to proceed?', '❯ 1. Yes', ' 2. No', 'Esc to cancel'].join('\n'); + expect(screenTail(dialog, 14, 900, '❯')).toBe(dialog); + expect(screenTail(idle)).toContain('❯'); // no glyph given: nothing is cut + }); + + it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => { + const screen = [ + 'old line', + '', + '\u001b[1mWhich option?\u001b[0m', + '─────────────────────────', + ' 1. Cap GPUs', + ' 2. Cap CPU', + '\u001b(B', + ' 4. Type something.', + '━━━━━━━━━━━━━━━━━━━━━━━━━', + 'Enter to select · Esc to cancel\u001b(B', + ].join('\n'); + expect(screenTail(screen)).toBe( + [ + 'old line', + 'Which option?', + ' 1. Cap GPUs', + ' 2. Cap CPU', + ' 4. Type something.', + 'Enter to select · Esc to cancel', + ].join('\n') + ); + }); + + it('caps lines and characters, cutting on a line', () => { + const many = Array.from({ length: 40 }, (_v, i) => `line ${i}`).join('\n'); + expect(screenTail(many).split('\n')).toHaveLength(14); + expect(screenTail(many).endsWith('line 39')).toBe(true); + const wide = Array.from({ length: 5 }, () => 'x'.repeat(400)).join('\n'); + expect(screenTail(wide).length).toBeLessThanOrEqual(900); + }); + + it('is empty for nothing', () => { + expect(screenTail('')).toBe(''); + expect(screenTail('\n\n ───── \n')).toBe(''); + }); +}); + +describe('ensureReceivedDir', () => { + let work: string; + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-recv-')); + }); + afterEach(() => rmSync(work, { recursive: true, force: true })); + + it('makes the folder and ignores it with a .gitignore of *, once', async () => { + const dir = await ensureReceivedDir(work); + expect(dir).toBe(join(work, '.codeman-discord')); + expect(readFileSync(join(dir!, '.gitignore'), 'utf8')).toBe('*\n'); + }); + + it('leaves a .gitignore that is already there alone', async () => { + mkdirSync(join(work, '.codeman-discord')); + writeFileSync(join(work, '.codeman-discord', '.gitignore'), 'mine\n'); + expect(await ensureReceivedDir(work)).toBe(join(work, '.codeman-discord')); + expect(readFileSync(join(work, '.codeman-discord', '.gitignore'), 'utf8')).toBe('mine\n'); + }); + + it('refuses a symlink or a plain file at the folder, and a workspace that is not a directory', async () => { + const elsewhere = mkdtempSync(join(tmpdir(), 'discord-elsewhere-')); + symlinkSync(elsewhere, join(work, '.codeman-discord')); + expect(await ensureReceivedDir(work)).toBeNull(); + expect(existsSync(join(elsewhere, '.gitignore'))).toBe(false); + rmSync(elsewhere, { recursive: true, force: true }); + rmSync(join(work, '.codeman-discord')); + writeFileSync(join(work, '.codeman-discord'), 'x'); + expect(await ensureReceivedDir(work)).toBeNull(); + expect(await ensureReceivedDir(join(work, 'missing'))).toBeNull(); + }); +}); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts new file mode 100644 index 000000000..0a8d86d15 --- /dev/null +++ b/test/discord-push-glue.test.ts @@ -0,0 +1,216 @@ +// @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[]; dispose: () => void }; + discordBridge: Record<string, ReturnType<typeof vi.fn>>; + sessions: Map<string, unknown>; + mux: { capturePaneText?: (name: string) => string | null }; + lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>; + sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>; + broadcast: (event: string, data: unknown) => void; + cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>; + discardPartiallyBuiltSession: (id: string) => Promise<void>; +}; + +function server(): Internals { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; + s.pushStore = { getAll: () => [], dispose: () => undefined }; + s.discordBridge = { + notify: vi.fn(async () => null), + sessionCreated: vi.fn(async () => undefined), + sessionRenamed: vi.fn(async () => undefined), + sessionDeleted: vi.fn(async () => undefined), + forgetTab: vi.fn(), + sessionIdle: vi.fn(async () => undefined), + stop: vi.fn(), + }; + return s; +} + +beforeEach(() => sendNotification.mockClear()); + +describe('bridge deps', () => { + it('agentExited is true only for a session whose pane exit was observed', () => { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & { + discordBridge: { deps: { agentExited: (id: string) => boolean } }; + }; + s.sessions.set('dead', { paneExit: { observedAt: 1 } }); + s.sessions.set('alive', {}); + const { agentExited } = s.discordBridge.deps; + expect(agentExited('dead')).toBe(true); + expect(agentExited('alive')).toBe(false); + expect(agentExited('missing')).toBe(false); + }); +}); + +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' }); + const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n'); + s.mux = { capturePaneText: capture }; + await s.sendPushNotifications('hook:permission_prompt', { + sessionId: 's1', + sessionName: 'w1-app', + tool_name: 'Bash', + }); + expect(s.discordBridge.notify).toHaveBeenCalledTimes(1); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect(msg).toMatchObject({ + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', + }); + // A function, so the screen is read (a synchronous tmux exec) only once the bridge will post. + expect(typeof msg.tail).toBe('function'); + expect(capture).not.toHaveBeenCalled(); + expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU'); + expect(capture).toHaveBeenCalledTimes(1); + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => { + const s = server(); + s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' }); + const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' })); + s.lastResponseFor = reader; + s.mux = { capturePaneText: () => 'screen\n❯\n status' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' }); + const idle = s.discordBridge.notify.mock.calls[0][0]; + expect(await idle.fullText()).toEqual({ + text: 'The full answer.\n\n- one\n- two', + version: '2026-10-30T00:00:00Z', + }); + expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut + await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' }); + const dialog = s.discordBridge.notify.mock.calls[1][0]; + expect(dialog.fullText).toBeUndefined(); + expect(dialog.tail()).toBe('screen\n❯\n status'); + }); + + it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])( + 'a finished turn in a %s tab posts the whole last message', + async (mode) => { + const s = server(); + s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode }); + s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' })); + s.mux = { capturePaneText: () => 'screen' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' }); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?'); + } + ); + + it('a shell tab has no turns, so it keeps the screen tail', async () => { + const s = server(); + s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' }); + s.lastResponseFor = vi.fn(async () => ({ text: 'x' })); + s.mux = { capturePaneText: () => 'prompt$' }; + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' }); + const msg = s.discordBridge.notify.mock.calls[0][0]; + expect(await msg.fullText()).toBeUndefined(); + expect(msg.tail()).toBe('prompt$'); + }); + + 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'); + }); + + it('does not touch the thread on session:deleted, which a detach also fires', () => { + const s = server(); + s.broadcast(SseEvent.SessionDeleted, { id: 's1' }); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => { + const s = server(); + await s.cleanupSession('gone', false); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + await s.cleanupSession('gone', true); + expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone'); + }); + + it('a discarded half-built session clears the per-tab state but keeps its thread', async () => { + const s = server(); + s.sessions.set('rebuild', { + owner: 'u', + ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined }, + removeAllListeners: () => undefined, + stop: async () => undefined, + }); + await s.discardPartiallyBuiltSession('rebuild'); + expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild'); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('ignores an event with no session id', () => { + const s = server(); + s.broadcast(SseEvent.SessionUpdated, {}); + expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled(); + }); + + it('allows the typed hint only for a local external CLI: not Claude, a shell, a remote or a Docker tab', () => { + const s = server(); + s.sessions.set('cx', { mode: 'codex' }); + s.sessions.set('cl', { mode: 'claude' }); + s.sessions.set('sh', { mode: 'shell' }); + s.sessions.set('rm', { mode: 'codex', remote: { label: 'box' } }); + s.sessions.set('dk', { mode: 'codex', docker: { image: 'x' } }); + for (const id of ['cx', 'cl', 'sh', 'rm', 'dk', 'missing']) s.broadcast(SseEvent.SessionIdle, { id }); + expect(s.discordBridge.sessionIdle.mock.calls).toEqual([ + ['cx', true], + ['cl', false], + ['sh', false], + ['rm', false], + ['dk', false], + ['missing', false], + ]); + }); + + it('closes the bridge when the server stops', async () => { + const s = server(); + await (s as unknown as { stop: () => Promise<void> }).stop(); + expect(s.discordBridge.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/discord-startup-hint.test.ts b/test/discord-startup-hint.test.ts new file mode 100644 index 000000000..661d183bf --- /dev/null +++ b/test/discord-startup-hint.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +/** + * @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the + * bridge and its `startupHint` setting are on, and never changes a launch that does not use it. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getCli } from '../src/config/cli-registry/registry.js'; +import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js'; +import { + DiscordBridge, + DISCORD_STARTUP_HINT, + getDiscordStartupHint, + writeDiscordConfig, + DEFAULT_DISCORD_CONFIG, +} from '../src/discord-bridge.js'; + +const SID = '0f9c2b14-1111-2222-3333-444455556666'; +const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'; + +function launch(mode: 'claude' | 'codex'): string | undefined { + return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null }); +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-hint-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +function bridge() { + return new DiscordBridge({ + configDir: dir, + fetchImpl: async () => new Response('{}'), + sendInput: async () => true, + socketFactory: () => ({ send() {}, close() {}, on() {} }), + }); +} + +describe('startup hint on the Claude launch', () => { + it('is absent by default, so a launch is byte-identical to before', () => { + expect(getDiscordStartupHint()).toBeUndefined(); + expect(launch('claude')).toBe(BASE); + }); + + it('is appended, single-quoted, while the bridge is on', async () => { + await writeDiscordConfig(dir, { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: 'tok', + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], + }); + const b = bridge(); + await b.reload(); + expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT); + expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`); + expect(DISCORD_STARTUP_HINT).not.toContain("'"); + expect(launch('codex')).not.toContain('append-system-prompt'); + b.stop(); + expect(launch('claude')).toBe(BASE); + }); + + it('is absent when startupHint is switched off', async () => { + await writeDiscordConfig(dir, { + ...DEFAULT_DISCORD_CONFIG, + enabled: true, + token: 'tok', + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], + startupHint: false, + }); + const b = bridge(); + await b.reload(); + expect(launch('claude')).toBe(BASE); + b.stop(); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index 3eb255472..854b2e6e2 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -4,7 +4,7 @@ * * ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()). */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -120,6 +120,22 @@ describe('/api/config-backups', () => { } }); + it('a restored discord.json reaches the running bridge (it caches its config)', async () => { + const reloadDiscord = vi.fn(async () => undefined); + const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord })); + writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 }); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 }); + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + expect(res.json().data.restored).toContain('discord.json'); + expect(reloadDiscord).toHaveBeenCalledTimes(1); + // A restore that does not carry the file leaves the bridge alone. + rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true }); + const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` }); + expect(reloadDiscord).toHaveBeenCalledTimes(1); + }); + it('a restored intents.json is not reverted by the next captured prompt', async () => { const { app } = await createRouteTestHarness(registerConfigBackupRoutes); const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-')); diff --git a/test/routes/discord-last-message-per-cli.test.ts b/test/routes/discord-last-message-per-cli.test.ts new file mode 100644 index 000000000..9926ec433 --- /dev/null +++ b/test/routes/discord-last-message-per-cli.test.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the + * Discord bridge posts from), with a message that asks SEVERAL questions: every question must come + * back whole, in order, on its own line, and survive the split into Discord posts. + * + * Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts. + * Port: N/A + * + * Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode, + * gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own + * transcript formats and are covered by their reader's tests. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { splitForDiscord } from '../../src/discord-bridge.js'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; + +const QUESTIONS = [ + 'Should the sweep run daily or hourly?', + 'Do you want archived threads deleted as well?', + 'Is one channel per Codeman acceptable?', +]; +const INTRO = 'I have three questions before I continue:'; +const OUTRO = 'Once you answer, I will apply the change.'; + +/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */ +const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n'); + +/** Every question present, in order, each on a line of its own. */ +function expectWholeQuestions(text: string): void { + let from = 0; + for (const q of QUESTIONS) { + const at = text.indexOf(q, from); + expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from); + from = at + q.length; + } + const lines = text.split('\n').map((l) => l.trim()); + for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true); + expect(text).toContain(INTRO); + expect(text).toContain(OUTRO); + // What goes to Discord: the same questions after the split, joined back. + expectWholeQuestionsAfterSplit(text); +} + +function expectWholeQuestionsAfterSplit(text: string): void { + const joined = splitForDiscord(text).join('\n'); + for (const q of QUESTIONS) expect(joined).toContain(q); +} + +async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + app.addHook('preSerialization', (req, _reply, payload: unknown, done) => { + if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown }; + return done(null, p.success === undefined ? { success: true, data: payload } : payload); + }); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('the last message with several questions, per CLI', () => { + let harness: { app: FastifyInstance; ctx: MockRouteContext }; + let home: string; + let prevHome: string | undefined; + let prevCodexHome: string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let session: any; + + async function lastText(): Promise<string> { + const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` }); + return JSON.parse(res.body).data.text as string; + } + + beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-')); + prevHome = process.env.HOME; + prevCodexHome = process.env.CODEX_HOME; + process.env.HOME = home; + process.env.CODEX_HOME = join(home, '.codex'); + harness = await createHarness(); + session = harness.ctx._session; + }); + + afterEach(async () => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodexHome; + rmSync(home, { recursive: true, force: true }); + await harness.app.close(); + }); + + it('claude: reads the whole message from the JSONL transcript', async () => { + const id = '40568a29-d4eb-4eb6-b671-8401428e4f39'; + session.mode = 'claude'; + session.claudeSessionId = id; + session.adoptClaudeSessionId = () => undefined; + const dir = join(home, '.claude', 'projects', '-workspace'); + mkdirSync(dir, { recursive: true }); + const row = (type: string, text: string, ts: string) => ({ + type, + timestamp: ts, + message: { content: [{ type: 'text', text }] }, + }); + writeFileSync( + join(dir, `${id}.jsonl`), + [row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')] + .map((e) => JSON.stringify(e)) + .join('\n') + ); + const text = await lastText(); + expect(text).toBe(MESSAGE); + expectWholeQuestions(text); + }); + + it('codex: reads the whole message from the rollout', async () => { + const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const workdir = join(home, 'work'); + mkdirSync(workdir, { recursive: true }); + session.mode = 'codex'; + session.workingDir = workdir; + session.codexConfig = { resumeSessionId: uuid }; + const dir = join(home, '.codex', 'sessions', '2026', '10', '30'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`); + const entries = [ + { + timestamp: '2026-10-30T00:00:00Z', + type: 'session_meta', + payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' }, + }, + { + timestamp: '2026-10-30T00:00:01Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'plan the cleanup' }, + }, + { + timestamp: '2026-10-30T00:00:02Z', + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] }, + }, + ]; + writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + utimesSync(file, 1_750_000_000, 1_750_000_000); + const text = await lastText(); + expectWholeQuestions(text); + }); + + for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) { + it(`${mode}: reads the whole message from the pane`, async () => { + session.mode = mode; + session.terminalBuffer = [ + '› plan the cleanup', + '', + 'model · project · main · Ready · Context 100% left', + '', + '• Called write_file', + '', + MESSAGE, + ].join('\n'); + expectWholeQuestions(await lastText()); + }); + } + + it('copilot: reads the whole message from the pane screen', async () => { + session.mode = 'copilot'; + const screen = [ + ' ❯ plan the cleanup 10:01', + '', + ` ● ${INTRO}`, + '', + ...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`), + '', + ` ${OUTRO}`, + '', + ' ~/project Session: 1 AIC used', + '────────────────────────────────', + ' ❯ ', + '────────────────────────────────', + ' ← open sidebar · / commands · ? help', + ].join('\n'); + harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux; + expectWholeQuestions(await lastText()); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts new file mode 100644 index 000000000..288b54c75 --- /dev/null +++ b/test/routes/discord-routes.test.ts @@ -0,0 +1,328 @@ +/** + * @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the + * settings routes are admin only in multi-user mode. Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js'; +import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js'; + +const TOKEN = 'MTA1.secret.token-value-1234567890'; +const FULL = { + enabled: true, + token: TOKEN, + guildId: '111111111111111111', + parentChannelId: '222222222222222222', + allowedUserIds: ['333333333333333333'], +}; + +let dir: string; +let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>; + +async function harness(authUser?: { username: string; role: 'admin' | 'user' }) { + const bridge = new DiscordBridge({ + configDir: dir, + fetchImpl, + sendInput: async () => true, + socketFactory: () => ({ send() {}, close() {}, on() {} }), + }); + const h = await createRouteTestHarness( + (app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }), + authUser ? { authUser } : undefined + ); + return { ...h, bridge }; +} + +beforeEach(() => { + _resetNotifyBuckets(); + dir = mkdtempSync(join(tmpdir(), 'discord-routes-')); + fetchImpl = vi.fn<DiscordFetch>( + async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 }) + ); +}); +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + rmSync(dir, { recursive: true, force: true }); +}); + +describe('/api/discord', () => { + it('starts disabled with no token', async () => { + const { app } = await harness(); + const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' }); + expect(data).not.toHaveProperty('token'); + }); + + it('saves, masks the token in every response and writes the file 0600', async () => { + const { app, bridge } = await harness(); + const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + expect(put.statusCode).toBe(200); + expect(put.body).not.toContain('secret'); + expect(put.json().data).toMatchObject({ enabled: true, hasToken: true }); + const get = await app.inject({ method: 'GET', url: '/api/discord' }); + expect(get.body).not.toContain('secret'); + expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600); + expect((await readDiscordConfig(dir)).token).toBe(TOKEN); + bridge.stop(); + }); + + it('keeps the token when only an option changes, and clears it on an empty string', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + await app.inject({ + method: 'PUT', + url: '/api/discord', + payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' }, + }); + const cfg = await readDiscordConfig(dir); + expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' }); + const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } }); + expect(cleared.json().data.hasToken).toBe(false); + bridge.stop(); + }); + + it('stores startupHint (default on) and typedHint (default off) and returns them', async () => { + const { app, bridge } = await harness(); + const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(first).toMatchObject({ startupHint: true, typedHint: false }); + const put = await app.inject({ + method: 'PUT', + url: '/api/discord', + payload: { typedHint: true, startupHint: false }, + }); + expect(put.json().data).toMatchObject({ startupHint: false, typedHint: true }); + bridge.stop(); + }); + + it('refuses a bad id, an incomplete enable, and unknown fields', async () => { + const { app } = await harness(); + const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } }); + expect(bad.statusCode).toBe(400); + const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } }); + expect(incomplete.statusCode).toBe(400); + const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } }); + expect(extra.statusCode).toBe(400); + }); + + it('POST /test needs a saved token, then posts to the parent channel', async () => { + const { app, bridge } = await harness(); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const res = await app.inject({ method: 'POST', url: '/api/discord/test' }); + expect(res.json().data.ok).toBe(true); + expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages'); + bridge.stop(); + }); + + it('is admin only in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await harness({ username: 'bob', role: 'user' }); + expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403); + expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403); + expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403); + }); +}); + +describe('POST /api/sessions/:id/discord-notify', () => { + const url = '/api/sessions/test-session-1/discord-notify'; + it('is refused while the bridge is off, and posts into the session thread once on', async () => { + const { app, bridge } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } }); + expect(off.statusCode).toBe(409); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } }); + expect(on.json().data.ok).toBe(true); + const sent = fetchImpl.mock.calls.map((c) => String(c[1].body)); + expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true); + 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("does not reveal another user's session through its id prefix (multi-user)", async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' }); + (ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice'; + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().error).not.toContain('test-session-1'); + bridge.stop(); + }); + + it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } }); + const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } }); + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + expect(second.json().data.deduped).toBeUndefined(); + const sent = fetchImpl.mock.calls.map((c) => String(c[1].body)); + expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2); + bridge.stop(); + }); + + it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 })); + const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ ok: false, status: 401 }); + bridge.stop(); + }); + + it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const codes: number[] = []; + for (let i = 0; i < 8; i++) { + codes.push( + (await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode + ); + } + expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]); + expect(codes.slice(6)).toEqual([429, 429]); + const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } }); + expect(limited.json().errorCode).toBe('RATE_LIMITED'); + expect(limited.headers['retry-after']).toBe('60'); + bridge.stop(); + }); + + it('does not spend rate-limit tokens on a request refused for its file', async () => { + const { app, bridge } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + for (let i = 0; i < 8; i++) { + const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } }); + expect(bad.statusCode).toBe(400); + } + expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200); + bridge.stop(); + }); + + it('rejects an empty or oversized message', async () => { + const { app } = await harness(); + const url = `/api/sessions/test-session-1/discord-notify`; + expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400); + expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400); + }); +}); + +describe('discord-notify with a file', () => { + let work: string; + let outside: string; + const url = '/api/sessions/test-session-1/discord-notify'; + + async function ready() { + const h = await harness(); + (h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work; + await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + return h; + } + const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) => + app.inject({ method: 'POST', url, payload: { message: 'log attached', file } }); + + beforeEach(() => { + work = mkdtempSync(join(tmpdir(), 'discord-work-')); + outside = mkdtempSync(join(tmpdir(), 'discord-outside-')); + writeFileSync(join(work, 'run.log'), 'hello'); + writeFileSync(join(outside, 'secret.txt'), 'nope'); + }); + afterEach(() => { + rmSync(work, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + it('uploads a file inside the session folder as multipart', async () => { + const { app, bridge } = await ready(); + const res = await post(app, join(work, 'run.log')); + expect(res.json().data.ok).toBe(true); + const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData; + expect((form.get('files[0]') as File).name).toBe('run.log'); + bridge.stop(); + }); + + it.each([ + ['a relative path', () => 'run.log'], + ['a file outside the folder', () => join(outside, 'secret.txt')], + ['the folder itself', () => work], + ['a missing file', () => join(work, 'nope.log')], + ])('refuses %s', async (_n, path) => { + const { app, bridge } = await ready(); + const res = await post(app, path()); + expect(res.statusCode).toBe(400); + expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false); + bridge.stop(); + }); + + it('refuses to send when the session folder is not a directory it can probe', async () => { + const { app, ctx, bridge } = await ready(); + (ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log'); + const res = await post(app, join(work, 'run.log')); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/session folder/); + bridge.stop(); + }); + + it('probes the file before realpath: a path that is not a regular file is refused', async () => { + const { app, bridge } = await ready(); + mkdirSync(join(work, 'dir')); + expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/); + bridge.stop(); + }); + + it('refuses a symlink that points outside the folder', async () => { + const { app, bridge } = await ready(); + mkdirSync(join(work, 'sub')); + symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt')); + expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400); + bridge.stop(); + }); + + it('refuses a file over 8 MB', async () => { + const { app, bridge } = await ready(); + writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1)); + const res = await post(app, join(work, 'big.bin')); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/8 MB/); + bridge.stop(); + }); + + it('refuses a file for a remote session', async () => { + const { app, bridge, ctx } = await ready(); + (ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' }; + expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400); + bridge.stop(); + }); +});