From 61bf26cfdfa1f421c9547773379de8da3a32cd92 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:04:18 +0800 Subject: [PATCH 01/24] feat(discord): two-way Discord bridge with a thread per tab Alerts for permission prompts, questions, idle and errors post into a per-tab thread; a reply from an allowlisted user is typed into that tab. Thread creation, thread cleanup on tab delete and pinging are configurable. POST /api/sessions/:id/discord-notify lets a CLI ask for the user. Setup is in docs/discord-bridge.md. --- .changeset/discord-bridge.md | 5 + CLAUDE.md | 2 +- docs/api-reference.md | 13 + docs/discord-bridge.md | 115 +++++ docs/security-architecture.md | 1 + src/config-backup.ts | 2 + src/discord-bridge.ts | 721 +++++++++++++++++++++++++++++ src/web/public/index.html | 76 +++ src/web/public/settings-ui.js | 106 +++++ src/web/routes/discord-routes.ts | 132 ++++++ src/web/routes/index.ts | 1 + src/web/schemas.ts | 28 ++ src/web/server.ts | 65 +++ test/discord-bridge.test.ts | 465 +++++++++++++++++++ test/routes/discord-routes.test.ts | 131 ++++++ 15 files changed, 1862 insertions(+), 1 deletion(-) create mode 100644 .changeset/discord-bridge.md create mode 100644 docs/discord-bridge.md create mode 100644 src/discord-bridge.ts create mode 100644 src/web/routes/discord-routes.ts create mode 100644 test/discord-bridge.test.ts create mode 100644 test/routes/discord-routes.test.ts diff --git a/.changeset/discord-bridge.md b/.changeset/discord-bridge.md new file mode 100644 index 000000000..484108c66 --- /dev/null +++ b/.changeset/discord-bridge.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +**A two-way Discord bridge: one thread per tab, with replies typed back in.** Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with the tail of what the CLI printed. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so a blocked CLI can be answered from a phone away from the network. A CLI can also ask for the person deliberately with `POST /api/sessions/:id/discord-notify` (a physical step, a blocker, a timed-out watch). Threads are created on the first alert or for every new tab, and are kept, archived or deleted with the tab; pings are off, critical only or every post. The bot token is stored in `discord.json` (0600) and never returned. Setup with least-privilege bot permissions is in `docs/discord-bridge.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b08..f779b90f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,7 +315,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//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//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/docs/api-reference.md b/docs/api-reference.md index 4a0cb6b72..4c2055b01 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?, pingMode?, scope? }` (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? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `409` while the bridge is disabled. | + +Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter, at most one per second per tab. + ## 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/discord-bridge.md b/docs/discord-bridge.md new file mode 100644 index 000000000..880d5cef5 --- /dev/null +++ b/docs/discord-bridge.md @@ -0,0 +1,115 @@ +# 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, stops or errors. +- 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. +- Lets a CLI post a deliberate message (for example "press the reset button on the case") with `POST /api/sessions/:id/discord-notify`. + +## 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 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: + + | 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 | + +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 six permissions add up to the permissions integer `309237713984`. + +### 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**, **Discord**. +2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist. +3. Choose the options below and press **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 | +| `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 | + +## 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 logs once and backs off. + +## Rotating and revoking + +- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Test**. +- **Revoke:** kick the bot from the server or delete the application, then delete `~/.codeman/discord.json`. +- 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..c7b0476ca 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. 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/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 = 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/discord-bridge.ts b/src/discord-bridge.ts new file mode 100644 index 000000000..23cb0c09f --- /dev/null +++ b/src/discord-bridge.ts @@ -0,0 +1,721 @@ +/** + * @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. + * - 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 { join } from 'node:path'; +import WebSocket from 'ws'; +import { WEBHOOK_SCOPES, type WebhookScope, type WebhookUrgency } from './types/push.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}$/; + +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; + pingMode: DiscordPingMode; + /** `attention` skips info-level events (a finished response); `all` sends them too. */ + scope: WebhookScope; +} + +export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { + enabled: false, + token: '', + guildId: '', + parentChannelId: '', + allowedUserIds: [], + threadCreation: 'lazy', + onSessionDelete: 'archive', + pingMode: 'critical', + scope: 'attention', +}; + +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; + host?: string; +} + +export interface DiscordResult { + ok: boolean; + status?: number; + error?: string; + at: number; +} + +export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error'; + +// --------------------------------------------------------------------------- +// 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. */ +export function sanitiseReply(raw: 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); + return trimmed ? `${trimmed}\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'; +} + +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)}**`; + const fence = msg.tail?.trim() ? `\n\`\`\`\n${msg.tail.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 = (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; + 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), + pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode), + scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope), + }; +} + +export async function readDiscordConfig(configDir: string): Promise { + 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 { + const tmp = `${target}.${process.pid}.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 { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); +} + +export async function readThreadMap(configDir: string): Promise> { + try { + const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) if (typeof v === 'string' && SNOWFLAKE.test(v)) out[k] = v; + return out; + } catch { + return {}; + } +} + +async function writeThreadMap(configDir: string, map: Record): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await writePrivate(join(configDir, THREADS_FILE), map, 0o600); +} + +// --------------------------------------------------------------------------- +// IO: REST +// --------------------------------------------------------------------------- + +export type DiscordFetch = (target: URL, init: RequestInit) => Promise; + +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 = (ms) => new Promise((r) => setTimeout(r, ms)) + ) {} + + async request(method: string, path: string, body?: unknown, retried = false): Promise { + const res = await this.fetchImpl(new URL(`${API}${path}`), { + method, + headers: { + Authorization: `Bot ${this.token()}`, + 'Content-Type': 'application/json', + 'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)', + }, + body: body === undefined ? undefined : 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(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); + } + rename(threadId: string, name: string): Promise { + return this.request('PATCH', `/channels/${threadId}`, { name }); + } + archive(threadId: string): Promise { + return this.request('PATCH', `/channels/${threadId}`, { archived: true }); + } + remove(threadId: string): Promise { + return this.request('DELETE', `/channels/${threadId}`); + } + react(channelId: string, messageId: string, emoji: string): Promise { + 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 }; +} + +/** 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 | null = null; + private retry: ReturnType | 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 { + 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 +// --------------------------------------------------------------------------- + +export interface DiscordBridgeDeps { + configDir: string; + fetchImpl: DiscordFetch; + /** Types `text` (already ending in Enter) into the session. False when the session is gone. */ + sendInput: (sessionId: string, text: string) => Promise; + socketFactory?: SocketFactory; + now?: () => number; + sleep?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; +} + +export class DiscordBridge { + private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG }; + private threads: Record = {}; + private loaded = false; + private gateway: DiscordGateway | null = null; + private lastSent = new Map(); + private lastReply = new Map(); + /** The last name seen per session, so only a real rename touches the thread. */ + private names = new Map(); + private pending = new Map>(); + 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; + } + + /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ + async reload(): Promise { + this.cfg = await readDiscordConfig(this.deps.configDir); + if (!this.loaded) { + this.threads = await readThreadMap(this.deps.configDir); + this.loaded = true; + } + this.gateway?.stop(); + this.gateway = null; + if (this.cfg.enabled && this.cfg.token) { + 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 { + this.gateway?.stop(); + this.gateway = null; + } + + 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 as { name?: string })?.name === 'TimeoutError' + ? 'Timed out' + : 'Network error'; + return { ok: false, status, error, at: this.now() }; + } + + /** The thread for a session, creating it when absent. Concurrent callers share one creation. */ + private threadFor(sessionId: string, name: string | undefined): Promise { + const existing = this.threads[sessionId]; + if (existing) return Promise.resolve(existing); + const inflight = this.pending.get(sessionId); + if (inflight) return inflight; + const p = (async () => { + try { + const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); + if (!t?.id) return null; + this.threads[sessionId] = t.id; + await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + return t.id; + } catch (err) { + this.last = this.fail(err); + return null; + } finally { + this.pending.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, explicitUrgent = false): Promise { + if (!this.loaded) await this.reload(); + if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; + const key = `${msg.event}:${msg.sessionId ?? ''}`; + const t = this.now(); + const prev = this.lastSent.get(key); + if (!explicitUrgent && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return null; + 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); + } + const post = buildPost(msg, this.cfg, explicitUrgent); + try { + let target = this.cfg.parentChannelId; + if (msg.sessionId) { + const thread = await this.threadFor(msg.sessionId, msg.sessionName); + if (!thread) return this.last; + target = thread; + } + try { + await this.api.post(target, post); + } 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 (!fresh) return this.last; + await this.api.post(fresh, post); + } else { + throw err; + } + } + 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 { + const saved = this.cfg; + this.cfg = cfg; + try { + await this.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)); + } finally { + this.cfg = saved; + } + } + + /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ + async sessionCreated(sessionId: string, name: string | undefined): Promise { + if (!this.loaded) await this.reload(); + if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; + await this.threadFor(sessionId, name); + } + + async sessionRenamed(sessionId: string, name: string | undefined): Promise { + if (this.names.get(sessionId) === (name ?? '')) return; + this.names.set(sessionId, name ?? ''); + const thread = this.threads[sessionId]; + if (!thread || !this.cfg.enabled) return; + await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined); + } + + /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ + async sessionDeleted(sessionId: string): Promise { + const thread = this.threads[sessionId]; + if (!thread) return; + delete this.threads[sessionId]; + this.lastReply.delete(sessionId); + this.names.delete(sessionId); + await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + 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); + } + + /** Inbound reply. Every gate here drops silently: a stranger learns nothing. */ + async onGatewayMessage(m: GatewayMessage): Promise { + 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) return; + this.lastReply.set(sessionId, t); + const text = sanitiseReply(m.content ?? ''); + if (!text) return; + const ok = await this.deps.sendInput(sessionId, text).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/web/public/index.html b/src/web/public/index.html index 6611e6c32..cdc1e1e9a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3234,6 +3234,82 @@

Notifications

+ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0bf5025c0..d979efbfa 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,111 @@ 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('discordPingMode').value = d.pingMode; + document.getElementById('discordScope').value = d.scope; + 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, + pingMode: document.getElementById('discordPingMode').value, + scope: document.getElementById('discordScope').value, + }; + 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/discord-routes.ts b/src/web/routes/discord-routes.ts new file mode 100644 index 000000000..3797fcdd7 --- /dev/null +++ b/src/web/routes/discord-routes.ts @@ -0,0 +1,132 @@ +/** + * @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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { findSessionOrFail, isAdmin, parseBody } from '../route-helpers.js'; +import type { SessionPort } from '../ports/session-port.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js'; +import { + discordConfigProblem, + maskToken, + readDiscordConfig, + writeDiscordConfig, + type DiscordBridge, + type DiscordConfig, + type DiscordGatewayState, + type DiscordResult, +} from '../../discord-bridge.js'; + +export interface DiscordRouteDeps { + bridge: DiscordBridge; + configDir: string; + ctx: SessionPort; + hostTitle: () => string; +} + +export type DiscordStatus = Omit & { + hasToken: boolean; + tokenMasked: string; + gateway: DiscordGatewayState; + gatewayError: string | null; + lastResult: DiscordResult | null; +}; + +export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void { + const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + const status = async (): Promise => { + 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> => { + const no = denied(req, reply); + if (no) return no; + return { success: true, data: await status() }; + }); + + app.put('/api/discord', async (req, reply): Promise> => { + 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> => { + 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> => { + const { id } = req.params as { id: string }; + const { message, urgent } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); + const session = findSessionOrFail(deps.ctx, id, req); + 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(), + }, + urgent === 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/schemas.ts b/src/web/schemas.ts index 6c1ce4746..ef55ae16e 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 '../discord-bridge.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,33 @@ 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(), + pingMode: z.enum(DISCORD_PING_MODES).optional(), + scope: z.enum(WEBHOOK_SCOPES).optional(), + }) + .strict(); + +/** POST /api/sessions/:id/discord-notify */ +export const DiscordNotifySchema = z + .object({ + message: z.string().min(1).max(1500), + urgent: z.boolean().optional(), + }) + .strict(); + // ========== Ralph Loop ========== /** POST /api/ralph-loop/start */ diff --git a/src/web/server.ts b/src/web/server.ts index ddfc296d2..b50e2f5ea 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 } from '../discord-bridge.js'; +import { stripAnsi } from '../utils/regex-patterns.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,17 @@ 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. */ + private discordBridge = new DiscordBridge({ + configDir: getDataDir(), + fetchImpl: webviewFetch, + sendInput: async (sessionId, text) => { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true; + return session.write(text, { fromUser: true }); + }, + }); private teamWatcher: TeamWatcher = new TeamWatcher(); private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null; private readonly titleHostname: string; @@ -1330,6 +1344,13 @@ export class WebServer extends EventEmitter { configDir: getDataDir(), hostTitle: () => this.windowTitle, }); + registerDiscordRoutes(this.app, { + bridge: this.discordBridge, + configDir: getDataDir(), + ctx, + hostTitle: () => this.windowTitle, + }); + if (!this.testMode) void this.discordBridge.reload().catch(() => undefined); registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -2699,6 +2720,24 @@ 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); + } + + /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */ + private bridgeSessionEvent(event: string, data: unknown): void { + if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionDeleted) { + return; + } + const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } }; + const s = d.session ?? d; + if (!s.id) return; + const run = + event === SseEvent.SessionDeleted + ? this.discordBridge.sessionDeleted(s.id) + : event === SseEvent.SessionCreated + ? this.discordBridge.sessionCreated(s.id, s.name).then(() => this.discordBridge.sessionRenamed(s.id!, s.name)) + : this.discordBridge.sessionRenamed(s.id, s.name); + void run.catch(() => undefined); } /** @@ -2934,6 +2973,22 @@ 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; + void this.discordBridge + .notify({ + event, + title: template.title, + body, + urgency: template.urgency as WebhookUrgency, + sessionId: sessionId || undefined, + sessionName: sessionName || undefined, + tail: dcSession ? WebServer.outputTail(dcSession) : undefined, + host: this.windowTitle, + }) + .catch(() => undefined); + const subscriptions = this.pushStore.getAll(); if (subscriptions.length === 0) return; @@ -3015,6 +3070,16 @@ export class WebServer extends EventEmitter { } } + /** The last few lines the CLI printed, with colour codes removed, for a Discord alert. */ + private static outputTail(session: { textOutput: string; terminalBuffer: string }): string { + const raw = session.textOutput || session.terminalBuffer.slice(-4000); + const lines = stripAnsi(raw) + .split(/\r?\n/) + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== ''); + return lines.slice(-12).join('\n').slice(-900); + } + /** The notification body for an event (shared by Web Push and the webhook channel). */ private static pushBodyText(event: string, data: Record, sessionName: string): string { let body = sessionName ? `[${sessionName}]` : ''; diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts new file mode 100644 index 000000000..891654ecf --- /dev/null +++ b/test/discord-bridge.test.ts @@ -0,0 +1,465 @@ +// @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 { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildPost, + DEFAULT_DISCORD_CONFIG, + DiscordApi, + DiscordBridge, + DiscordGateway, + discordConfigPath, + discordConfigProblem, + maskToken, + readDiscordConfig, + readThreadMap, + sanitiseReply, + shouldPing, + threadNameFor, + writeDiscordConfig, + type DiscordConfig, + 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).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 | 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)[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('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 | undefined }>; + let sendInput: ReturnType Promise>>; + let t: number; + + function bridge(overrides: Partial = {}, 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 the same event within 3 s but lets an explicit urgent post through', async () => { + const b = bridge(); + await b.notify(MSG); + await b.notify(MSG); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(1); + await b.notify(MSG, true); + expect(posts.filter((p) => p.path.endsWith('/messages'))).toHaveLength(2); + 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 = {}) => ({ + 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'); + 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'); + 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(); + }); + }); + + 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('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(); + }); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts new file mode 100644 index 000000000..b69eeaa1d --- /dev/null +++ b/test/routes/discord-routes.test.ts @@ -0,0 +1,131 @@ +/** + * @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 { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { 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>; + +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(() => { + dir = mkdtempSync(join(tmpdir(), 'discord-routes-')); + fetchImpl = vi.fn( + 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' } }); + const cfg = await readDiscordConfig(dir); + expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete' }); + const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } }); + expect(cleared.json().data.hasToken).toBe(false); + 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', () => { + 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('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); + }); +}); From 1c4338e8b51de5729364aab841ef0c21dbe3a020 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:24:56 +0800 Subject: [PATCH 02/24] feat(discord): codeman notify, startup hint for every CLI, and file send/receive Adds the codeman notify command, a startup hint (Claude via --append-system-prompt, other CLIs once on first idle), files saved from thread attachments into the tab folder, and --file uploads limited to the session folder. --- docs/api-reference.md | 445 +++++++++++++++-------------- docs/discord-bridge.md | 56 ++-- src/cli.ts | 83 ++++++ src/config/cli-registry/schema.ts | 2 + src/config/cli-registry/stock.ts | 13 + src/config/cli-registry/types.ts | 2 + src/discord-bridge.ts | 157 +++++++++- src/session-cli-registry-bridge.ts | 5 + src/web/public/index.html | 7 + src/web/public/settings-ui.js | 2 + src/web/routes/discord-routes.ts | 41 ++- src/web/schemas.ts | 3 + src/web/server.ts | 38 ++- test/discord-bridge.test.ts | 182 +++++++++++- test/discord-startup-hint.test.ts | 82 ++++++ test/routes/discord-routes.test.ts | 74 ++++- 16 files changed, 947 insertions(+), 245 deletions(-) create mode 100644 test/discord-startup-hint.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 4c2055b01..cdda55ae9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -74,22 +74,22 @@ The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in `src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on the HTTP status. -| `errorCode` | HTTP | Meaning | -|-------------|------|---------| -| `INVALID_INPUT` | 400 | Malformed request / failed validation | -| `UNAUTHORIZED` | 401 | Authentication required or failed | -| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | -| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | -| `NOT_FOUND` | 404 | Resource does not exist | -| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | -| `SESSION_BUSY` | 409 | Session is busy | -| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | -| `ALREADY_EXISTS` | 409 | Resource already exists | -| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | -| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | -| `OPERATION_FAILED` | 422 | Well-formed but could not be completed | -| `RATE_LIMITED` | 429 | Too many requests | -| `INTERNAL_ERROR` | 500 | Unexpected server error | +| `errorCode` | HTTP | Meaning | +| -------------------------- | ---- | -------------------------------------------------------------------------------------------- | +| `INVALID_INPUT` | 400 | Malformed request / failed validation | +| `UNAUTHORIZED` | 401 | Authentication required or failed | +| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | +| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | +| `NOT_FOUND` | 404 | Resource does not exist | +| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | +| `SESSION_BUSY` | 409 | Session is busy | +| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | +| `ALREADY_EXISTS` | 409 | Resource already exists | +| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | +| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | +| `OPERATION_FAILED` | 422 | Well-formed but could not be completed | +| `RATE_LIMITED` | 429 | Too many requests | +| `INTERNAL_ERROR` | 500 | Unexpected server error | Adding a new error code is non-breaking; removing or renaming one is a major change. @@ -100,17 +100,17 @@ duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/. is the unversioned alias. These routes use the response envelope above; the table lists the value inside `data` on success. -| Method | Path | Request body | Response `data` | -| --- | --- | --- | --- | -| GET | `/api/v1/cron/jobs` | None | `CronJob[]` | -| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | -| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | -| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | -| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | -| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | -| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | -| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | -| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | +| Method | Path | Request body | Response `data` | +| ------ | ------------------------------- | ------------------------- | ------------------------------------------- | +| GET | `/api/v1/cron/jobs` | None | `CronJob[]` | +| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | +| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | +| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | +| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | +| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | +| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | +| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | +| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | ### Job request fields @@ -118,26 +118,26 @@ The create body requires `name`, `agentType`, `workingDir`, `promptMode`, `inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields are required according to the selected prompt and schedule: -| Field | Type / validation | -| --- | --- | -| `name` | String, 1–200 characters | -| `agentType` | A supported session mode (including `shell`) | -| `workingDir` | Existing, allowed working-directory path | -| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | -| `promptMode` | `inline_text` or `prompt_file_path` | -| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | -| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | -| `inputMode` | `paste` or `typed` | -| `scheduleType` | `once`, `interval`, `daily`, or `weekly` | -| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | -| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | -| `dailyTime` | Required for `daily`; `HH:MM` in server-local time | -| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | -| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | -| `enabled` | Boolean | -| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | -| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | -| `notes` | Optional string, at most 2000 characters | +| Field | Type / validation | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `name` | String, 1–200 characters | +| `agentType` | A supported session mode (including `shell`) | +| `workingDir` | Existing, allowed working-directory path | +| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | +| `promptMode` | `inline_text` or `prompt_file_path` | +| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | +| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | +| `inputMode` | `paste` or `typed` | +| `scheduleType` | `once`, `interval`, `daily`, or `weekly` | +| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | +| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | +| `dailyTime` | Required for `daily`; `HH:MM` in server-local time | +| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | +| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | +| `enabled` | Boolean | +| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | +| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | +| `notes` | Optional string, at most 2000 characters | `PUT /jobs/:id` accepts any subset of these fields, then validates the merged job. When changing `promptMode` or `scheduleType`, supply the fields the new mode needs. @@ -187,10 +187,10 @@ exist because SSE is Codeman's only other "tell me when" channel, and an agent driving the API from a shell tool cannot practically hold a stream and parse events inline. -| Call | Blocks until | -|------|--------------| -| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires | -| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output | +| Call | Blocks until | +| --------------------------------------------- | -------------------------------------------------- | +| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires | +| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output | | `POST /api/v1/sessions/:id/input` with `wait` | the input is delivered **and then** a signal fires | `POST .../input` with `wait` is not the same as a `POST` followed by a separate @@ -244,13 +244,13 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send ### Signals -| Signal | Source | Actually fires for | -|--------|--------|--------------------| -| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) | -| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph | -| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | -| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | -| `exit` | no process is behind the session | every mode | +| Signal | Source | Actually fires for | +| --------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) | +| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph | +| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | +| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | +| `exit` | no process is behind the session | every mode | `stop` is the signal to orchestrate on where it exists; `idle` is a heuristic fallback that can flap mid-turn when a spinner pauses. The default set when `until` @@ -265,7 +265,7 @@ the 90 s startup window), because the dialog's own ❯ is not a composer. ⚠️ **`exit` means "nothing is running", which includes "not started yet".** The server answers from `pid === null` plus a mux-layer pane-death probe, and that -covers a session that exited — including a worker that died *inside* its tmux pane +covers a session that exited — including a worker that died _inside_ its tmux pane while the local attach client (and therefore `pid`) lives on — one that was detached, and one that was **created but never started**. So the first wait after `POST /api/v1/sessions` returns `{"signal":"exit","immediate":true}` in @@ -299,7 +299,7 @@ turn ended. ⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell session emits its one `idle` at startup and then stays `status: "idle"` forever, -whatever the pane is doing, so it never emits a *transition*. Since send-and-wait +whatever the pane is doing, so it never emits a _transition_. Since send-and-wait requires a transition (and so does `fresh=1`), both can only time out there: a documented default `wait` on a shell worker running `sleep 4` times out at the full 25 s. Synchronize hook-less sessions with `wait-output` and a unique marker @@ -333,11 +333,11 @@ with `from=buffer` keeps matching long after the dialog is gone. A worked versio ### `GET /api/v1/sessions/:id/wait` -| Param | Type | Default | Notes | -|-------|------|---------|-------| -| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback | -| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` | -| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time | +| Param | Type | Default | Notes | +| --------- | -------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback | +| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` | +| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time | ```bash curl -s "$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000" @@ -354,12 +354,12 @@ a plain signal wait, so check the endpoint path before blaming the parameters. ### `GET /api/v1/sessions/:id/wait-output` -| Param | Type | Default | Notes | -|-------|------|---------|-------| -| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found | -| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing | -| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking | -| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` | +| Param | Type | Default | Notes | +| --------- | ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found | +| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing | +| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking | +| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` | **Matching is literal, never a pattern.** A `regex` parameter is rejected with a `400` rather than ignored, so a caller that assumed otherwise finds out immediately @@ -411,10 +411,10 @@ hand-written query string decodes to a space. Two optional fields on the existing endpoint: -| Field | Type | Notes | -|-------|------|-------| -| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" | -| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` | +| Field | Type | Notes | +| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" | +| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` | Both are `nullish`, so an explicit `null` from `JSON.stringify` is accepted as "absent" rather than failing validation. That is deliberate: `.optional()` would @@ -474,10 +474,10 @@ reporting `delivered:true` plus a timeout. Two endpoints back that flow directly, both scoped to one session's remote host and both refusing a session that is not remote (`400 INVALID_INPUT`): -| Method | Path | Purpose | -| --- | --- | --- | -| `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). | -| `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. | +| Method | Path | Purpose | +| ------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). | +| `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. | ⚠️ Waking is deliberately reachable only from an explicit user action (this route, a session create/attach, or typing into a sleeping session). No watcher, dropped-session @@ -491,16 +491,24 @@ All three nest the wait result under `data.wait`, so one client helper works aga any of them: ```json -{ "success": true, "data": { - "sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464", - "status": "idle", - "limitPaused": false, - "wait": { - "signal": "stop", "until": ["stop", "idle", "exit"], - "timedOut": false, "immediate": false, "ended": false, "aborted": false, - "waitedMs": 8421, "timeoutMs": 60000 +{ + "success": true, + "data": { + "sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464", + "status": "idle", + "limitPaused": false, + "wait": { + "signal": "stop", + "until": ["stop", "idle", "exit"], + "timedOut": false, + "immediate": false, + "ended": false, + "aborted": false, + "waitedMs": 8421, + "timeoutMs": 60000 + } } -}} +} ``` `POST .../input` returns the same `wait` object alongside `delivered`, `duplicate`, @@ -514,21 +522,21 @@ redelivery (harmless, the turn it refers to may be long over), while with client that reads `delivered === false` as "duplicate" silently treats a failed send as a success. -| Field | Type | Meaning | -|-------|------|---------| -| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) | -| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) | -| `wait.matched` | boolean | the string appeared (`/wait-output` only) | -| `wait.match` | string | the literal that was searched for (`/wait-output` only) | -| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) | -| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` | -| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) | -| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met | -| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome | -| `wait.waitedMs` | number | wall-clock ms actually spent waiting | -| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied | -| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand | -| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard | +| Field | Type | Meaning | +| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) | +| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) | +| `wait.matched` | boolean | the string appeared (`/wait-output` only) | +| `wait.match` | string | the literal that was searched for (`/wait-output` only) | +| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) | +| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` | +| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) | +| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met | +| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome | +| `wait.waitedMs` | number | wall-clock ms actually spent waiting | +| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied | +| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand | +| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard | Read the outcome by discriminator, in this order: @@ -551,12 +559,12 @@ read the timeout as "the worker is wedged" and kill a session that was working f ### Errors -| `errorCode` | HTTP | When | -|-------------|------|------| -| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | -| `NOT_FOUND` | 404 | no such session, or one this caller does not own | -| `SESSION_BUSY` | 409 | this session's waiter cap is full | -| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | +| `errorCode` | HTTP | When | +| --------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | +| `NOT_FOUND` | 404 | no such session, or one this caller does not own | +| `SESSION_BUSY` | 409 | this session's waiter cap is full | +| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | The two capacity codes are deliberately different. A process-wide cap reported as `SESSION_BUSY` would tell the caller to switch sessions, which cannot help. The @@ -589,12 +597,12 @@ the alternate screen) the bytes a `tail` cut dropped are earlier repaints that n request returns. The capture runs synchronous tmux calls on the server; the `Server-Timing` header reports `capture`, `prepare` and `total`. -| Query | Meaning | -|---|---| -| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | -| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | -| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | -| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | +| Query | Meaning | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | +| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | +| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | +| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | ## Experimental bounded agent inspection and guarded input @@ -618,11 +626,11 @@ These authenticated routes use the standard envelope and session ownership check `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. -| Endpoint | Parameters/body | Result | -| --- | --- | --- | -| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | -| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | -| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | +| Endpoint | Parameters/body | Result | +| ---------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | +| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | +| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | Inspection initially supports native local Codex JSONL with strict conversation attribution, using the session's effective `CODEX_HOME`, explicit resume identity @@ -768,12 +776,12 @@ receiver's pane, which is the difference from `POST …/input`: a post does not start or interrupt a turn, and the text may span several lines. Design and the reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558). -| Method | Route | Body / query | Answer (`data`) | -|---|---|---|---| -| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` | -| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` | -| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` | -| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` | +| Method | Route | Body / query | Answer (`data`) | +| ------ | -------------------------------- | ------------------------------ | --------------------------------------------------------------------- | +| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` | +| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` | +| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` | +| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` | A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or `null` (the user); `seen` says whether the receiver had read it before this read. @@ -980,13 +988,13 @@ session runs as far as the server knows it, for the web UI's session headers: `source` is where it came from, strongest first: -| `source` | Meaning | -| ----------------- | ----------------------------------------------------------------------------------------------------- | -| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | -| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | -| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | +| `source` | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | +| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | +| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | | `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. | -| `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | +| `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | Between `statusline` and `screen` the newest report wins. The field is absent when no model is known (a shell, a CLI that reports none and was launched without one). `model` @@ -1005,16 +1013,16 @@ change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): "promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 } ``` -| Field | Meaning | -| --------------------- | --------------------------------------------------------------------------------------------------------- | -| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | -| `ttl` | `5m` or `1h`; absent when Claude reported neither. | -| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | -| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | -| `misses` | Requests that re-processed content the cache already held. | -| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | -| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | -| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | +| Field | Meaning | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | +| `ttl` | `5m` or `1h`; absent when Claude reported neither. | +| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | +| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | +| `misses` | Requests that re-processed content the cache already held. | +| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | +| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | +| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | | `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | The whole object is absent until the first report, and restored after a server restart @@ -1075,9 +1083,9 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md). - `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first, ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId, - sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, - toolSummary?, message?, cwd?, context?, options?: {n, label}[], - acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame; +sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, +toolSummary?, message?, cwd?, context?, options?: {n, label}[], +acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame; `options` is present only when the dialog's numbered choices parsed confidently; `acknowledgedAt` marks an item a human has already looked at (see `/viewed` below) and tells clients not to re-arm its tab alert. Listing @@ -1092,11 +1100,11 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md). `options`), or `{ action: 'text', text }` (idle prompts only; submits the line as a prompt). `404 NOT_FOUND` when the item is no longer pending, `400 INVALID_INPUT` for an `answerInTerminal` item (` approvals must be - answered in the terminal`), `409 CONFLICT` when the dialog left the screen or +answered in the terminal`), `409 CONFLICT` when the dialog left the screen or another actor answered first, `422 OPERATION_FAILED` when the session refused input. - `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes. - `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId, - acknowledged: itemId | null }`. Marks the session's pending **idle** item as +acknowledged: itemId | null }`. Marks the session's pending **idle** item as seen by a human (the web UI calls it when you open the session's tab): the item stays pending and answerable, but stops arming the yellow tab alert on every client, including after a reload. Permission/question items are never @@ -1125,14 +1133,14 @@ since they live in the CLI's own transcript store and stay reachable from the Resume list. A plan nobody spends expires after 24 hours. - `GET /api/v1/reboot-restore` → `{ sessions: RestorableSession[], - scrollbackRestored: false }`, ownership-scoped in multi-user mode. +scrollbackRestored: false }`, ownership-scoped in multi-user mode. `RestorableSession`: `{ id, name?, workingDir, mode, owner? }`. The persisted record itself is never sent. `scrollbackRestored` is always `false` and exists so a client states it: a restored session is a NEW pane, so the conversation continues and the terminal history does not. - `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit to restore everything the caller can see) → `{ restored: RestorableSession[], - skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` +skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` (the directory is gone), `workspace-forbidden` (in multi-user mode it is outside the workspace of the user the session belongs to, re-checked against that owner's current grant rather than the caller's), `already-live` (the conversation is already @@ -1163,7 +1171,7 @@ user guide: [`readmymind.md`](readmymind.md). - `GET /api/v1/sessions/:id/intent` -> `{ intent: IntentProfile }` for the session's case. `IntentProfile`: `{ key, workingDir, updatedAt, goals, - recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap +recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap 50, each <= 500 chars). A case with nothing recorded answers an empty profile with `updatedAt: 0`; nothing is persisted by reads. - `PUT /api/v1/sessions/:id/intent` with `{ goals }` (<= 8192 chars, strict @@ -1287,7 +1295,7 @@ minSafeContextTokens, slotCount?}` — `slotCount` is present whenever the model harness. - `POST /api/v1/quick-start`'s `customModel: { endpointId, modelId, confirmed?, confirmedContext?, confirmedSwap? }` field (alongside its -normal `caseName`/`mode`/etc. body) + normal `caseName`/`mode`/etc. body) computes the same injection **before** the session exists and launches directly on the endpoint — no restart, because there was never a native-backend boot to restart away from. Runs the identical checks as @@ -1321,11 +1329,11 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou `GET /api/sessions/:id/git-diff?repo=&path=&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here. -**Which repositories.** git finds a repository by walking *up* from the session's working directory, so: +**Which repositories.** git finds a repository by walking _up_ from the session's working directory, so: - Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it. - **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s. -- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored. +- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that _is_ that repository's root is not ignored. - A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer. Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported. @@ -1333,7 +1341,7 @@ Both routes accept two optional query parameters, which the UI sends from its pe `data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only): - `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is: - `branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`. + `branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged _and_ modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`. - `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below. - `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host). - `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted). @@ -1359,11 +1367,30 @@ A local case whose folder is the top of a git repository can keep **worktrees** "worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees", "truncated": false, "worktrees": [ - { "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false, - "locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] }, - { "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login", - "main": false, "isCase": false, "managed": true, "name": "fix-login", - "sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] } + { + "path": "/home/me/codeman-cases/myrepo", + "head": "6910cb15…", + "branch": "main", + "detached": false, + "bare": false, + "locked": false, + "prunable": false, + "main": true, + "isCase": true, + "managed": false, + "sessionCount": 0, + "sessions": [] + }, + { + "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", + "branch": "worktree-fix-login", + "main": false, + "isCase": false, + "managed": true, + "name": "fix-login", + "sessionCount": 1, + "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] + } ] } ``` @@ -1386,15 +1413,15 @@ A local case whose folder is the top of a git repository can keep **worktrees** `~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`. -| Method | Path | Body | Notes | -| ------ | ----------------------------- | ------------------------------------ | ----- | -| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | -| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | -| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | -| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | -| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | -| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | -| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| Method | Path | Body | Notes | +| ------ | ----------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | +| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | +| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | +| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | +| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). @@ -1404,35 +1431,35 @@ A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as b The same multi-user rule covers the operations that act on the whole server. For a non-admin: -| Method | Path | Multi-user non-admin | -| ------ | ----------------------------- | -------------------- | -| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) | -| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) | -| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) | -| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) | -| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session | -| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins | +| Method | Path | Multi-user non-admin | +| ------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) | +| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) | +| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) | +| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) | +| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session | +| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins | ## CLI management Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched. -| Method | Path | Body | Notes | -| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. | -| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. | -| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. | -| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. | -| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | -| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | +| Method | Path | Body | Notes | +| -------- | ----------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. | +| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. | +| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. | +| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. | +| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | +| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | ## MCP server sync Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`. -| Method | Path | Body | Notes | -| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | +| Method | Path | Body | Notes | +| ------ | --------------- | ---- | --------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | | `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. | Result (`data`): @@ -1453,9 +1480,9 @@ The result carries server **names** only, never `env` values, `headers` or file Claude Code reports the main conversation's prompt-cache state on its statusline (v2.1.251+); the exporter keeps it per session as `promptCache` on `GET /api/sessions` (`{ warm, ttl, expiresAt, recacheTokensIfCold, misses, lastMissCauses }`, epoch ms). The keep-warm sends one keep-alive turn shortly before an idle session's cache would expire; `keepWarmEnabled` (absent = the app-wide `promptCacheKeepWarm` setting applies, which a session spawned by another session does not inherit), the live `keepWarm` status and `lastRealPromptAt` (epoch ms of the last submit that was not a keep-alive, the cap's clock) ride the same session state. A keep-alive is a submit, so it moves `lastSubmitAt` like any prompt. A claude launch with keep-warm on also carries `"idleCompaction": false` in its `--settings` JSON (Claude Code 2.1.290 or newer). -| Method | Path | Body | Notes | -| ------ | ---------------------------------- | ------------------------------------ | ----- | -| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | +| Method | Path | Body | Notes | +| ------ | -------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | `status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, no push is sent for them and the run summary does not record them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. @@ -1467,22 +1494,22 @@ Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.j Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). A key absent from `settings.json` means the retention the newest backup in the folder recorded, and the default only when none did. Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. -| Method | Path | Body | Notes | -| ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | -| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | -| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | +| Method | Path | Body | Notes | +| ------ | ------------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | +| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | +| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | ## Webhook notifications Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. -| Method | Path | Body | Notes | -| ------ | -------------------- | -------------------------------------------- | ----- | -| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. | -| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. | -| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. | +| Method | Path | Body | Notes | +| ------ | ------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. | +| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. | +| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. | 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. @@ -1490,14 +1517,14 @@ Delivery goes through the same egress guard as web tabs (refused on the resolved 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?, pingMode?, scope? }` (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? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `409` while the bridge is disabled. | +| 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?, pingMode?, scope?, startupHint? }` (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. `409` while the bridge is disabled. 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, at most one per second per tab. +Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter, 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 @@ -1511,7 +1538,7 @@ same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synce [`claude-voice-plan.md`](claude-voice-plan.md). - `GET /api/v1/voice/status` -> `{ available, reason?, subscriptionType?, - expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody +expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody signed in to Claude Code on the server), `expired` (the access token elapsed; running any Claude session refreshes it) or `malformed`. The OAuth token itself is never returned by this or any other endpoint. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 880d5cef5..ab5cb8c0e 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -10,7 +10,9 @@ Codeman can post to a private Discord server when a session needs you, give ever - Posts when a session asks for permission, asks a question, goes idle, stops or errors. - 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. -- Lets a CLI post a deliberate message (for example "press the reset button on the case") with `POST /api/sessions/:id/discord-notify`. +- 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`. +- 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 `. +- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)). ## Setup @@ -46,21 +48,23 @@ On the **Bot** tab: On **OAuth2**, **URL Generator**: 1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else. -2. Tick only these permissions: - - | 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 | +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 six permissions add up to the permissions integer `309237713984`. +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 @@ -84,12 +88,28 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push ## Options -| Option | Values | Default | Effect | -| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- | -| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | -| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | -| `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 | +| 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 | +| `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 each CLI about `codeman notify` (Claude at launch, others once on their first idle) | + +## Telling the CLIs + +A CLI only posts deliberately if it knows it can. While the bridge and `startupHint` are on: + +- **Claude** is launched with `--append-system-prompt`, so nothing shows in the conversation. +- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs and shell tabs are never sent it. + +The text explains `codeman notify "message" --urgent`. `codeman` is already on the PATH of every session 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 `/.codeman-discord/-` (mode 0600) and the one-line reply the CLI sees ends with `[attached: ]`. 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. Add `.codeman-discord/` to your `.gitignore`. Remote (ssh) tabs have no local folder, so no file is saved for them. +- **Sent:** `codeman notify "message" --file ` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, so a CLI cannot post `~/.codeman/discord.json` or an ssh key. 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 diff --git a/src/cli.ts b/src/cli.ts index 4682aa895..5faee1ef1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,6 +92,89 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin }); } +/** POST JSON to the Codeman API with the same credentials `attach` uses. Resolves to the HTTP status, or 0. */ +async function postJson( + apiUrl: string, + apiPath: string, + payload: unknown +): Promise<{ status: number; error?: string }> { + const { username, password } = readCodemanCredentials(); + const url = new URL(apiPath, apiUrl); + const body = JSON.stringify(payload); + const transport = url.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }; + if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'POST', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + let text = ''; + res.on('data', (c) => (text += c)); + res.on('end', () => { + let error: string | undefined; + try { + error = (JSON.parse(text) as { error?: string }).error; + } catch { + /* not JSON */ + } + resolve({ status: res.statusCode ?? 0, error }); + }); + } + ); + req.on('error', () => resolve({ status: 0 })); + req.write(body); + req.end(); + }); +} + +program + .command('notify ') + .description( + "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 ', 'Send a file from the session folder with the message') + .option('-s, --session ', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') + .option('--url ', 'Codeman API URL (defaults to CODEMAN_API_URL)') + .action(async (message: string, options) => { + const sessionId = options.session || process.env.CODEMAN_SESSION_ID; + const apiUrl = options.url || process.env.CODEMAN_API_URL; + if (!sessionId || !apiUrl) { + console.error(palette.err('✗ notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)')); + process.exit(1); + } + const payload: { message: string; urgent?: boolean; file?: string } = { message }; + if (options.urgent) payload.urgent = true; + if (options.file) payload.file = isAbsolute(options.file) ? options.file : join(process.cwd(), options.file); + const { status, error } = await postJson( + apiUrl, + `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, + payload + ); + if (status >= 200 && status < 300) { + console.log(palette.ok('✓ Posted to Discord')); + } else if (status === 409) { + console.log('The Discord bridge is off. Say it in the terminal instead.'); + } else { + console.error( + palette.err(`✗ Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}`) + ); + process.exit(1); + } + }); + program .command('attach ') .description('Show an attachment card for a local file') 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_` — codex's unique per-pane rollout originator. */ | 'codemanPrefixedSessionId' /** diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 23cb0c09f..18406bb91 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -60,6 +60,8 @@ export interface DiscordConfig { 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; } export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { @@ -72,6 +74,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { onSessionDelete: 'archive', pingMode: 'critical', scope: 'attention', + startupHint: true, }; export interface DiscordMessage { @@ -84,6 +87,13 @@ export interface DiscordMessage { /** The tail of what the CLI last printed, so the reader can see what it is asking. */ tail?: string; 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; } export interface DiscordResult { @@ -122,11 +132,15 @@ export function maskToken(token: string): string { 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. */ -export function sanitiseReply(raw: string): string | null { +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); - return trimmed ? `${trimmed}\r` : null; + // 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 { @@ -145,6 +159,47 @@ export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency): 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 (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 . 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 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; +} + +/** 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[] }; @@ -187,6 +242,7 @@ function coerce(raw: unknown): DiscordConfig { onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete), 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, }; } @@ -256,14 +312,16 @@ export class DiscordApi { ) {} async request(method: string, path: string, body?: unknown, retried = false): Promise { + const isForm = typeof FormData !== 'undefined' && body instanceof FormData; const res = await this.fetchImpl(new URL(`${API}${path}`), { method, headers: { Authorization: `Bot ${this.token()}`, - 'Content-Type': 'application/json', + // 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 : JSON.stringify(body), + body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body), redirect: 'manual', signal: AbortSignal.timeout(SEND_TIMEOUT_MS), }); @@ -287,6 +345,12 @@ export class DiscordApi { 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 { return this.request('PATCH', `/channels/${threadId}`, { name }); } @@ -314,6 +378,7 @@ export interface GatewayMessage { 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. */ @@ -501,6 +566,8 @@ export interface DiscordBridgeDeps { fetchImpl: DiscordFetch; /** Types `text` (already ending in Enter) into the session. False when the session is gone. */ sendInput: (sessionId: string, text: string) => Promise; + /** 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; @@ -516,6 +583,8 @@ export class DiscordBridge { private lastReply = new Map(); /** The last name seen per session, so only a real rename touches the thread. */ private names = new Map(); + /** Brand-new sessions that have not yet been sent the startup hint (non-Claude CLIs). */ + private fresh = new Set(); private pending = new Map>(); private last: DiscordResult | null = null; private readonly api: DiscordApi; @@ -545,6 +614,7 @@ export class DiscordBridge { } this.gateway?.stop(); this.gateway = null; + activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; if (this.cfg.enabled && this.cfg.token) { this.gateway = new DiscordGateway( () => this.cfg.token, @@ -559,6 +629,7 @@ export class DiscordBridge { stop(): void { this.gateway?.stop(); this.gateway = null; + activeStartupHint = undefined; } private fail(err: unknown): DiscordResult { @@ -623,15 +694,16 @@ export class DiscordBridge { if (!thread) return this.last; target = thread; } + const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post)); try { - await this.api.post(target, post); + 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 (!fresh) return this.last; - await this.api.post(fresh, post); + await send(fresh); } else { throw err; } @@ -669,12 +741,25 @@ export class DiscordBridge { } /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ - async sessionCreated(sessionId: string, name: string | undefined): Promise { + async sessionCreated(sessionId: string, name: string | undefined, createdAt?: number): Promise { if (!this.loaded) await this.reload(); + // 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; await this.threadFor(sessionId, name); } + /** + * 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, external: boolean): Promise { + if (!this.fresh.delete(sessionId)) return; + if (!external || !this.cfg.enabled || !this.cfg.startupHint) return; + const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`); + if (text) await this.deps.sendInput(sessionId, text).catch(() => false); + } + async sessionRenamed(sessionId: string, name: string | undefined): Promise { if (this.names.get(sessionId) === (name ?? '')) return; this.names.set(sessionId, name ?? ''); @@ -696,6 +781,55 @@ export class DiscordBridge { else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined); } + private say(channelId: string, content: string): Promise { + 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.' }; + const target = join(dir, RECEIVED_DIR); + 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; + } + await fs.mkdir(target, { recursive: true }); + const dest = join(target, `${m.id}-${name}`); + await fs.writeFile(dest, buf, { mode: 0o600 }); + 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. Every gate here drops silently: a stranger learns nothing. */ async onGatewayMessage(m: GatewayMessage): Promise { if (!this.cfg.enabled || m.author?.bot) return; @@ -707,8 +841,13 @@ export class DiscordBridge { const prev = this.lastReply.get(sessionId); if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) return; this.lastReply.set(sessionId, t); - const text = sanitiseReply(m.content ?? ''); - if (!text) return; + 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); const ok = await this.deps.sendInput(sessionId, text).catch(() => false); const reply = ok ? this.api.react(m.channel_id, m.id, '✅') 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/web/public/index.html b/src/web/public/index.html index cdc1e1e9a..2bf3dda30 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3286,6 +3286,13 @@

Notifications

+
+
+ Tell each CLI how to reach you + Claude is told at launch. Other CLIs are sent one short message when a new tab first becomes idle. It explains the codeman notify command, for when a CLI is blocked on something only you can do. Never sent to shell tabs. +
+ +
Which events diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d979efbfa..3e76fd86c 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1769,6 +1769,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('discordOnSessionDelete').value = d.onSessionDelete; document.getElementById('discordPingMode').value = d.pingMode; document.getElementById('discordScope').value = d.scope; + document.getElementById('discordStartupHint').checked = d.startupHint !== false; 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'}` }; @@ -1792,6 +1793,7 @@ Object.assign(CodemanApp.prototype, { onSessionDelete: document.getElementById('discordOnSessionDelete').value, pingMode: document.getElementById('discordPingMode').value, scope: document.getElementById('discordScope').value, + startupHint: document.getElementById('discordStartupHint').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) diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 3797fcdd7..c8e7da36d 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -11,6 +11,8 @@ * 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 { findSessionOrFail, isAdmin, parseBody } from '../route-helpers.js'; @@ -19,6 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js'; import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js'; import { discordConfigProblem, + MAX_FILE_BYTES, maskToken, readDiscordConfig, writeDiscordConfig, @@ -43,6 +46,32 @@ export type DiscordStatus = Omit & { lastResult: DiscordResult | null; }; +/** + * 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'; + 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 | null => { if (isMultiUserMode() && !isAdmin(req)) { @@ -109,8 +138,17 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise> => { const { id } = req.params as { id: string }; - const { message, urgent } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); + const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); const session = findSessionOrFail(deps.ctx, id, req); + let upload: { name: string; data: Uint8Array } | undefined; + if (file !== undefined) { + const problem = await fileProblem(session.workingDir, !!session.remote, file); + if (typeof problem === 'string') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem); + } + upload = problem; + } const result = await deps.bridge.notify( { event: 'session:discord-notify', @@ -120,6 +158,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe sessionId: session.id, sessionName: session.name, host: deps.hostTitle(), + file: upload, }, urgent === true ); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ef55ae16e..e012c8189 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2231,6 +2231,7 @@ export const DiscordUpdateSchema = z onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(), pingMode: z.enum(DISCORD_PING_MODES).optional(), scope: z.enum(WEBHOOK_SCOPES).optional(), + startupHint: z.boolean().optional(), }) .strict(); @@ -2239,6 +2240,8 @@ 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(); diff --git a/src/web/server.ts b/src/web/server.ts index b50e2f5ea..2a10dbd29 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -519,6 +519,11 @@ export class WebServer extends EventEmitter { if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true; return session.write(text, { fromUser: true }); }, + // 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; @@ -2723,20 +2728,41 @@ export class WebServer extends EventEmitter { this.bridgeSessionEvent(event, data); } + private isExternalSession(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return !!session && isExternalCliMode(session.mode); + } + /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */ private bridgeSessionEvent(event: string, data: unknown): void { - if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionDeleted) { + if ( + event !== SseEvent.SessionCreated && + event !== SseEvent.SessionUpdated && + event !== SseEvent.SessionDeleted && + event !== SseEvent.SessionIdle + ) { return; } - const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } }; + 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.SessionDeleted - ? this.discordBridge.sessionDeleted(s.id) - : event === SseEvent.SessionCreated - ? this.discordBridge.sessionCreated(s.id, s.name).then(() => this.discordBridge.sessionRenamed(s.id!, s.name)) - : this.discordBridge.sessionRenamed(s.id, s.name); + ? this.discordBridge.sessionDeleted(id) + : event === SseEvent.SessionIdle + ? // Claude is told at launch; a shell must never be typed at. + this.discordBridge.sessionIdle(id, this.isExternalSession(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); } diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 891654ecf..a553f2e9b 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -3,12 +3,15 @@ * @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 { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildPost, + DISCORD_STARTUP_HINT, + isDiscordCdnUrl, + safeAttachmentName, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -463,3 +466,180 @@ describe('DiscordBridge', () => { }); }); }); + +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)['Content-Type']).toBeUndefined(); + }); +}); + +describe('DiscordBridge files and startup hint', () => { + const reply = (over: Record = {}) => ({ + id: 'm1', + 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]>; + let posted: Array<{ path: string; body?: Record }>; + let downloads: string[]; + + function make(cfg: Partial = {}, dirFor: (id: string) => string | undefined = () => work) { + 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) => (sent.push([id, text]), true), + sessionDir: dirFor, + socketFactory: () => new FakeSocket(), + now: () => t, + sleep: async () => undefined, + }); + return writeDiscordConfig(dir, { ...CFG, ...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', 'm1-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`]]); + 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(DISCORD_STARTUP_HINT).toContain('codeman notify'); + b.stop(); + }); + + 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(); + }); +}); 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/discord-routes.test.ts b/test/routes/discord-routes.test.ts index b69eeaa1d..5befb8e28 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -3,7 +3,7 @@ * settings routes are admin only in multi-user mode. Port: N/A (app.inject()). */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, rmSync, statSync } from 'node:fs'; +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'; @@ -129,3 +129,75 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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>['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 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(); + }); +}); From dcf51b171532e8dbfce67ce220ff374f40a19b62 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:30:52 +0800 Subject: [PATCH 03/24] fix(discord): post the rendered screen, not the raw stream, as the alert tail A full-screen UI repaints in place, so the raw output stream read as garbage. Read capture-pane instead and drop rules, blanks and escape codes. --- src/discord-bridge.ts | 18 ++++++++++++++++ src/web/server.ts | 21 +++++++++---------- test/discord-bridge.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 18406bb91..f548359f1 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -184,6 +184,24 @@ 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. + */ +export function screenTail(screen: string, maxLines = 14, maxChars = 900): string { + const 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)); + let out = lines.slice(-maxLines); + while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1); + return out.join('\n').slice(-maxChars); +} + /** 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(/^[._]+/, ''); diff --git a/src/web/server.ts b/src/web/server.ts index 2a10dbd29..b7b092ce0 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,8 +45,7 @@ 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 } from '../discord-bridge.js'; -import { stripAnsi } from '../utils/regex-patterns.js'; +import { DiscordBridge, screenTail } from '../discord-bridge.js'; import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; @@ -3010,7 +3009,7 @@ export class WebServer extends EventEmitter { urgency: template.urgency as WebhookUrgency, sessionId: sessionId || undefined, sessionName: sessionName || undefined, - tail: dcSession ? WebServer.outputTail(dcSession) : undefined, + tail: dcSession ? this.discordTail(dcSession) : undefined, host: this.windowTitle, }) .catch(() => undefined); @@ -3096,14 +3095,14 @@ export class WebServer extends EventEmitter { } } - /** The last few lines the CLI printed, with colour codes removed, for a Discord alert. */ - private static outputTail(session: { textOutput: string; terminalBuffer: string }): string { - const raw = session.textOutput || session.terminalBuffer.slice(-4000); - const lines = stripAnsi(raw) - .split(/\r?\n/) - .map((l) => l.trimEnd()) - .filter((l) => l.trim() !== ''); - return lines.slice(-12).join('\n').slice(-900); + /** + * 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: { muxName?: string | null; textOutput: string }): string { + const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null; + return screenTail(pane ?? session.textOutput ?? ''); } /** The notification body for an event (shared by Web Push and the webhook channel). */ diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index a553f2e9b..f7143ca79 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -12,6 +12,7 @@ import { DISCORD_STARTUP_HINT, isDiscordCdnUrl, safeAttachmentName, + screenTail, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -643,3 +644,43 @@ describe('DiscordBridge files and startup hint', () => { off.stop(); }); }); + +describe('screenTail', () => { + 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(''); + }); +}); From e3ca07d59d469b4854742ca69fcc1627b3c739c5 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:39:49 +0800 Subject: [PATCH 04/24] docs(discord): restore api-reference.md formatting, keep only the Discord section A formatter run re-padded every table in the file and broke the cron docs test, which matches the original spacing. --- docs/api-reference.md | 443 ++++++++++++++++++++---------------------- 1 file changed, 208 insertions(+), 235 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index cdda55ae9..9ceca0d0c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -74,22 +74,22 @@ The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in `src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on the HTTP status. -| `errorCode` | HTTP | Meaning | -| -------------------------- | ---- | -------------------------------------------------------------------------------------------- | -| `INVALID_INPUT` | 400 | Malformed request / failed validation | -| `UNAUTHORIZED` | 401 | Authentication required or failed | -| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | -| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | -| `NOT_FOUND` | 404 | Resource does not exist | -| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | -| `SESSION_BUSY` | 409 | Session is busy | -| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | -| `ALREADY_EXISTS` | 409 | Resource already exists | -| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | -| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | -| `OPERATION_FAILED` | 422 | Well-formed but could not be completed | -| `RATE_LIMITED` | 429 | Too many requests | -| `INTERNAL_ERROR` | 500 | Unexpected server error | +| `errorCode` | HTTP | Meaning | +|-------------|------|---------| +| `INVALID_INPUT` | 400 | Malformed request / failed validation | +| `UNAUTHORIZED` | 401 | Authentication required or failed | +| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | +| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | +| `NOT_FOUND` | 404 | Resource does not exist | +| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | +| `SESSION_BUSY` | 409 | Session is busy | +| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | +| `ALREADY_EXISTS` | 409 | Resource already exists | +| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | +| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | +| `OPERATION_FAILED` | 422 | Well-formed but could not be completed | +| `RATE_LIMITED` | 429 | Too many requests | +| `INTERNAL_ERROR` | 500 | Unexpected server error | Adding a new error code is non-breaking; removing or renaming one is a major change. @@ -100,17 +100,17 @@ duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/. is the unversioned alias. These routes use the response envelope above; the table lists the value inside `data` on success. -| Method | Path | Request body | Response `data` | -| ------ | ------------------------------- | ------------------------- | ------------------------------------------- | -| GET | `/api/v1/cron/jobs` | None | `CronJob[]` | -| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | -| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | -| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | -| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | -| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | -| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | -| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | -| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | +| Method | Path | Request body | Response `data` | +| --- | --- | --- | --- | +| GET | `/api/v1/cron/jobs` | None | `CronJob[]` | +| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | +| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | +| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | +| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | +| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | +| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | +| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | +| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | ### Job request fields @@ -118,26 +118,26 @@ The create body requires `name`, `agentType`, `workingDir`, `promptMode`, `inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields are required according to the selected prompt and schedule: -| Field | Type / validation | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `name` | String, 1–200 characters | -| `agentType` | A supported session mode (including `shell`) | -| `workingDir` | Existing, allowed working-directory path | -| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | -| `promptMode` | `inline_text` or `prompt_file_path` | -| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | -| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | -| `inputMode` | `paste` or `typed` | -| `scheduleType` | `once`, `interval`, `daily`, or `weekly` | -| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | -| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | -| `dailyTime` | Required for `daily`; `HH:MM` in server-local time | -| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | -| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | -| `enabled` | Boolean | -| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | -| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | -| `notes` | Optional string, at most 2000 characters | +| Field | Type / validation | +| --- | --- | +| `name` | String, 1–200 characters | +| `agentType` | A supported session mode (including `shell`) | +| `workingDir` | Existing, allowed working-directory path | +| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | +| `promptMode` | `inline_text` or `prompt_file_path` | +| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | +| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | +| `inputMode` | `paste` or `typed` | +| `scheduleType` | `once`, `interval`, `daily`, or `weekly` | +| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | +| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | +| `dailyTime` | Required for `daily`; `HH:MM` in server-local time | +| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | +| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | +| `enabled` | Boolean | +| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | +| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | +| `notes` | Optional string, at most 2000 characters | `PUT /jobs/:id` accepts any subset of these fields, then validates the merged job. When changing `promptMode` or `scheduleType`, supply the fields the new mode needs. @@ -187,10 +187,10 @@ exist because SSE is Codeman's only other "tell me when" channel, and an agent driving the API from a shell tool cannot practically hold a stream and parse events inline. -| Call | Blocks until | -| --------------------------------------------- | -------------------------------------------------- | -| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires | -| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output | +| Call | Blocks until | +|------|--------------| +| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires | +| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output | | `POST /api/v1/sessions/:id/input` with `wait` | the input is delivered **and then** a signal fires | `POST .../input` with `wait` is not the same as a `POST` followed by a separate @@ -244,13 +244,13 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send ### Signals -| Signal | Source | Actually fires for | -| --------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) | -| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph | -| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | -| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | -| `exit` | no process is behind the session | every mode | +| Signal | Source | Actually fires for | +|--------|--------|--------------------| +| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) | +| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph | +| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | +| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | +| `exit` | no process is behind the session | every mode | `stop` is the signal to orchestrate on where it exists; `idle` is a heuristic fallback that can flap mid-turn when a spinner pauses. The default set when `until` @@ -265,7 +265,7 @@ the 90 s startup window), because the dialog's own ❯ is not a composer. ⚠️ **`exit` means "nothing is running", which includes "not started yet".** The server answers from `pid === null` plus a mux-layer pane-death probe, and that -covers a session that exited — including a worker that died _inside_ its tmux pane +covers a session that exited — including a worker that died *inside* its tmux pane while the local attach client (and therefore `pid`) lives on — one that was detached, and one that was **created but never started**. So the first wait after `POST /api/v1/sessions` returns `{"signal":"exit","immediate":true}` in @@ -299,7 +299,7 @@ turn ended. ⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell session emits its one `idle` at startup and then stays `status: "idle"` forever, -whatever the pane is doing, so it never emits a _transition_. Since send-and-wait +whatever the pane is doing, so it never emits a *transition*. Since send-and-wait requires a transition (and so does `fresh=1`), both can only time out there: a documented default `wait` on a shell worker running `sleep 4` times out at the full 25 s. Synchronize hook-less sessions with `wait-output` and a unique marker @@ -333,11 +333,11 @@ with `from=buffer` keeps matching long after the dialog is gone. A worked versio ### `GET /api/v1/sessions/:id/wait` -| Param | Type | Default | Notes | -| --------- | -------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback | -| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` | -| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time | +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback | +| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` | +| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time | ```bash curl -s "$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000" @@ -354,12 +354,12 @@ a plain signal wait, so check the endpoint path before blaming the parameters. ### `GET /api/v1/sessions/:id/wait-output` -| Param | Type | Default | Notes | -| --------- | ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | -| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found | -| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing | -| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking | -| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` | +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found | +| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing | +| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking | +| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` | **Matching is literal, never a pattern.** A `regex` parameter is rejected with a `400` rather than ignored, so a caller that assumed otherwise finds out immediately @@ -411,10 +411,10 @@ hand-written query string decodes to a space. Two optional fields on the existing endpoint: -| Field | Type | Notes | -| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" | -| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` | +| Field | Type | Notes | +|-------|------|-------| +| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" | +| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` | Both are `nullish`, so an explicit `null` from `JSON.stringify` is accepted as "absent" rather than failing validation. That is deliberate: `.optional()` would @@ -474,10 +474,10 @@ reporting `delivered:true` plus a timeout. Two endpoints back that flow directly, both scoped to one session's remote host and both refusing a session that is not remote (`400 INVALID_INPUT`): -| Method | Path | Purpose | -| ------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). | -| `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. | +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). | +| `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. | ⚠️ Waking is deliberately reachable only from an explicit user action (this route, a session create/attach, or typing into a sleeping session). No watcher, dropped-session @@ -491,24 +491,16 @@ All three nest the wait result under `data.wait`, so one client helper works aga any of them: ```json -{ - "success": true, - "data": { - "sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464", - "status": "idle", - "limitPaused": false, - "wait": { - "signal": "stop", - "until": ["stop", "idle", "exit"], - "timedOut": false, - "immediate": false, - "ended": false, - "aborted": false, - "waitedMs": 8421, - "timeoutMs": 60000 - } +{ "success": true, "data": { + "sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464", + "status": "idle", + "limitPaused": false, + "wait": { + "signal": "stop", "until": ["stop", "idle", "exit"], + "timedOut": false, "immediate": false, "ended": false, "aborted": false, + "waitedMs": 8421, "timeoutMs": 60000 } -} +}} ``` `POST .../input` returns the same `wait` object alongside `delivered`, `duplicate`, @@ -522,21 +514,21 @@ redelivery (harmless, the turn it refers to may be long over), while with client that reads `delivered === false` as "duplicate" silently treats a failed send as a success. -| Field | Type | Meaning | -| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) | -| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) | -| `wait.matched` | boolean | the string appeared (`/wait-output` only) | -| `wait.match` | string | the literal that was searched for (`/wait-output` only) | -| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) | -| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` | -| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) | -| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met | -| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome | -| `wait.waitedMs` | number | wall-clock ms actually spent waiting | -| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied | -| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand | -| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard | +| Field | Type | Meaning | +|-------|------|---------| +| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) | +| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) | +| `wait.matched` | boolean | the string appeared (`/wait-output` only) | +| `wait.match` | string | the literal that was searched for (`/wait-output` only) | +| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) | +| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` | +| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) | +| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met | +| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome | +| `wait.waitedMs` | number | wall-clock ms actually spent waiting | +| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied | +| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand | +| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard | Read the outcome by discriminator, in this order: @@ -559,12 +551,12 @@ read the timeout as "the worker is wedged" and kill a session that was working f ### Errors -| `errorCode` | HTTP | When | -| --------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | -| `NOT_FOUND` | 404 | no such session, or one this caller does not own | -| `SESSION_BUSY` | 409 | this session's waiter cap is full | -| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | +| `errorCode` | HTTP | When | +|-------------|------|------| +| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | +| `NOT_FOUND` | 404 | no such session, or one this caller does not own | +| `SESSION_BUSY` | 409 | this session's waiter cap is full | +| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | The two capacity codes are deliberately different. A process-wide cap reported as `SESSION_BUSY` would tell the caller to switch sessions, which cannot help. The @@ -597,12 +589,12 @@ the alternate screen) the bytes a `tail` cut dropped are earlier repaints that n request returns. The capture runs synchronous tmux calls on the server; the `Server-Timing` header reports `capture`, `prepare` and `total`. -| Query | Meaning | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | -| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | -| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | -| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | +| Query | Meaning | +|---|---| +| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | +| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | +| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | +| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | ## Experimental bounded agent inspection and guarded input @@ -626,11 +618,11 @@ These authenticated routes use the standard envelope and session ownership check `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. -| Endpoint | Parameters/body | Result | -| ---------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | -| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | -| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | +| Endpoint | Parameters/body | Result | +| --- | --- | --- | +| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | +| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | +| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | Inspection initially supports native local Codex JSONL with strict conversation attribution, using the session's effective `CODEX_HOME`, explicit resume identity @@ -776,12 +768,12 @@ receiver's pane, which is the difference from `POST …/input`: a post does not start or interrupt a turn, and the text may span several lines. Design and the reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558). -| Method | Route | Body / query | Answer (`data`) | -| ------ | -------------------------------- | ------------------------------ | --------------------------------------------------------------------- | -| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` | -| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` | -| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` | -| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` | +| Method | Route | Body / query | Answer (`data`) | +|---|---|---|---| +| `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` | +| `GET` | `/api/v1/sessions/:id/inbox` | `?wait=` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` | +| `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` | +| `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` | A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or `null` (the user); `seen` says whether the receiver had read it before this read. @@ -988,13 +980,13 @@ session runs as far as the server knows it, for the web UI's session headers: `source` is where it came from, strongest first: -| `source` | Meaning | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | -| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | -| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | +| `source` | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | +| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | +| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | | `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. | -| `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | +| `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | Between `statusline` and `screen` the newest report wins. The field is absent when no model is known (a shell, a CLI that reports none and was launched without one). `model` @@ -1013,16 +1005,16 @@ change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): "promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 } ``` -| Field | Meaning | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | -| `ttl` | `5m` or `1h`; absent when Claude reported neither. | -| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | -| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | -| `misses` | Requests that re-processed content the cache already held. | -| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | -| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | -| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | +| Field | Meaning | +| --------------------- | --------------------------------------------------------------------------------------------------------- | +| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | +| `ttl` | `5m` or `1h`; absent when Claude reported neither. | +| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | +| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | +| `misses` | Requests that re-processed content the cache already held. | +| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | +| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | +| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | | `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | The whole object is absent until the first report, and restored after a server restart @@ -1083,9 +1075,9 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md). - `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first, ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId, -sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, -toolSummary?, message?, cwd?, context?, options?: {n, label}[], -acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame; + sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, + toolSummary?, message?, cwd?, context?, options?: {n, label}[], + acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame; `options` is present only when the dialog's numbered choices parsed confidently; `acknowledgedAt` marks an item a human has already looked at (see `/viewed` below) and tells clients not to re-arm its tab alert. Listing @@ -1100,11 +1092,11 @@ acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pa `options`), or `{ action: 'text', text }` (idle prompts only; submits the line as a prompt). `404 NOT_FOUND` when the item is no longer pending, `400 INVALID_INPUT` for an `answerInTerminal` item (` approvals must be -answered in the terminal`), `409 CONFLICT` when the dialog left the screen or + answered in the terminal`), `409 CONFLICT` when the dialog left the screen or another actor answered first, `422 OPERATION_FAILED` when the session refused input. - `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes. - `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId, -acknowledged: itemId | null }`. Marks the session's pending **idle** item as + acknowledged: itemId | null }`. Marks the session's pending **idle** item as seen by a human (the web UI calls it when you open the session's tab): the item stays pending and answerable, but stops arming the yellow tab alert on every client, including after a reload. Permission/question items are never @@ -1133,14 +1125,14 @@ since they live in the CLI's own transcript store and stay reachable from the Resume list. A plan nobody spends expires after 24 hours. - `GET /api/v1/reboot-restore` → `{ sessions: RestorableSession[], -scrollbackRestored: false }`, ownership-scoped in multi-user mode. + scrollbackRestored: false }`, ownership-scoped in multi-user mode. `RestorableSession`: `{ id, name?, workingDir, mode, owner? }`. The persisted record itself is never sent. `scrollbackRestored` is always `false` and exists so a client states it: a restored session is a NEW pane, so the conversation continues and the terminal history does not. - `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit to restore everything the caller can see) → `{ restored: RestorableSession[], -skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` + skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` (the directory is gone), `workspace-forbidden` (in multi-user mode it is outside the workspace of the user the session belongs to, re-checked against that owner's current grant rather than the caller's), `already-live` (the conversation is already @@ -1171,7 +1163,7 @@ user guide: [`readmymind.md`](readmymind.md). - `GET /api/v1/sessions/:id/intent` -> `{ intent: IntentProfile }` for the session's case. `IntentProfile`: `{ key, workingDir, updatedAt, goals, -recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap + recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap 50, each <= 500 chars). A case with nothing recorded answers an empty profile with `updatedAt: 0`; nothing is persisted by reads. - `PUT /api/v1/sessions/:id/intent` with `{ goals }` (<= 8192 chars, strict @@ -1295,7 +1287,7 @@ minSafeContextTokens, slotCount?}` — `slotCount` is present whenever the model harness. - `POST /api/v1/quick-start`'s `customModel: { endpointId, modelId, confirmed?, confirmedContext?, confirmedSwap? }` field (alongside its - normal `caseName`/`mode`/etc. body) +normal `caseName`/`mode`/etc. body) computes the same injection **before** the session exists and launches directly on the endpoint — no restart, because there was never a native-backend boot to restart away from. Runs the identical checks as @@ -1329,11 +1321,11 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou `GET /api/sessions/:id/git-diff?repo=&path=&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here. -**Which repositories.** git finds a repository by walking _up_ from the session's working directory, so: +**Which repositories.** git finds a repository by walking *up* from the session's working directory, so: - Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it. - **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s. -- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that _is_ that repository's root is not ignored. +- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored. - A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer. Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported. @@ -1341,7 +1333,7 @@ Both routes accept two optional query parameters, which the UI sends from its pe `data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only): - `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is: - `branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged _and_ modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`. + `branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`. - `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below. - `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host). - `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted). @@ -1367,30 +1359,11 @@ A local case whose folder is the top of a git repository can keep **worktrees** "worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees", "truncated": false, "worktrees": [ - { - "path": "/home/me/codeman-cases/myrepo", - "head": "6910cb15…", - "branch": "main", - "detached": false, - "bare": false, - "locked": false, - "prunable": false, - "main": true, - "isCase": true, - "managed": false, - "sessionCount": 0, - "sessions": [] - }, - { - "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", - "branch": "worktree-fix-login", - "main": false, - "isCase": false, - "managed": true, - "name": "fix-login", - "sessionCount": 1, - "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] - } + { "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false, + "locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] }, + { "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login", + "main": false, "isCase": false, "managed": true, "name": "fix-login", + "sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] } ] } ``` @@ -1413,15 +1386,15 @@ A local case whose folder is the top of a git repository can keep **worktrees** `~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`. -| Method | Path | Body | Notes | -| ------ | ----------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | -| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | -| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | -| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | -| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | -| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | -| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| Method | Path | Body | Notes | +| ------ | ----------------------------- | ------------------------------------ | ----- | +| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. | +| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). | +| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. | +| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | +| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). @@ -1431,35 +1404,35 @@ A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as b The same multi-user rule covers the operations that act on the whole server. For a non-admin: -| Method | Path | Multi-user non-admin | -| ------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) | -| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) | -| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) | -| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) | -| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session | -| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins | +| Method | Path | Multi-user non-admin | +| ------ | ----------------------------- | -------------------- | +| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) | +| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) | +| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) | +| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) | +| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session | +| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins | ## CLI management Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched. -| Method | Path | Body | Notes | -| -------- | ----------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. | -| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. | -| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. | -| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. | -| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | -| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | +| Method | Path | Body | Notes | +| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. | +| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. | +| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. | +| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. | +| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | +| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | ## MCP server sync Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`. -| Method | Path | Body | Notes | -| ------ | --------------- | ---- | --------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | +| Method | Path | Body | Notes | +| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. | | `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. | Result (`data`): @@ -1480,9 +1453,9 @@ The result carries server **names** only, never `env` values, `headers` or file Claude Code reports the main conversation's prompt-cache state on its statusline (v2.1.251+); the exporter keeps it per session as `promptCache` on `GET /api/sessions` (`{ warm, ttl, expiresAt, recacheTokensIfCold, misses, lastMissCauses }`, epoch ms). The keep-warm sends one keep-alive turn shortly before an idle session's cache would expire; `keepWarmEnabled` (absent = the app-wide `promptCacheKeepWarm` setting applies, which a session spawned by another session does not inherit), the live `keepWarm` status and `lastRealPromptAt` (epoch ms of the last submit that was not a keep-alive, the cap's clock) ride the same session state. A keep-alive is a submit, so it moves `lastSubmitAt` like any prompt. A claude launch with keep-warm on also carries `"idleCompaction": false` in its `--settings` JSON (Claude Code 2.1.290 or newer). -| Method | Path | Body | Notes | -| ------ | -------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | +| Method | Path | Body | Notes | +| ------ | ---------------------------------- | ------------------------------------ | ----- | +| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | `status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, no push is sent for them and the run summary does not record them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. @@ -1494,22 +1467,22 @@ Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.j Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). A key absent from `settings.json` means the retention the newest backup in the folder recorded, and the default only when none did. Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. -| Method | Path | Body | Notes | -| ------ | ------------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | -| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | -| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | +| Method | Path | Body | Notes | +| ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | +| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | +| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | ## Webhook notifications Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. -| Method | Path | Body | Notes | -| ------ | ------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. | -| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. | -| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. | +| Method | Path | Body | Notes | +| ------ | -------------------- | -------------------------------------------- | ----- | +| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. | +| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. | +| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. | 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. @@ -1517,12 +1490,12 @@ Delivery goes through the same egress guard as web tabs (refused on the resolved 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?, pingMode?, scope?, startupHint? }` (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. `409` while the bridge is disabled. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | +| 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?, pingMode?, scope?, startupHint? }` (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. `409` while the bridge is disabled. 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, 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)). @@ -1538,7 +1511,7 @@ same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synce [`claude-voice-plan.md`](claude-voice-plan.md). - `GET /api/v1/voice/status` -> `{ available, reason?, subscriptionType?, -expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody + expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody signed in to Claude Code on the server), `expired` (the access token elapsed; running any Claude session refreshes it) or `malformed`. The OAuth token itself is never returned by this or any other endpoint. From 902d4491e50b89876921f01eca39e332d352f4cc Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:45:26 +0800 Subject: [PATCH 05/24] feat(discord): testable codeman notify, short-id route, wiki page, docs and glue tests Moves the notify command into src/cli-notify.ts with tests, lets discord-notify take the 8-character id a Docker case exports, adds the wiki page and index entries, rewrites the stale changeset and security notes, and pins the WebServer wiring. --- .changeset/discord-bridge.md | 7 +- CLAUDE.md | 2 + README.md | 2 + docs/security-architecture.md | 2 +- docs/wiki/Config-Backups.md | 5 +- docs/wiki/Discord-Bridge.md | 92 ++++++++++++++++++ docs/wiki/Home.md | 1 + docs/wiki/Notifications-And-Approvals.md | 6 ++ docs/wiki/_Sidebar.md | 1 + src/cli-notify.ts | 103 +++++++++++++++++++++ src/cli.ts | 76 +-------------- src/web/routes/discord-routes.ts | 13 ++- test/cli-notify.test.ts | 101 ++++++++++++++++++++ test/discord-push-glue.test.ts | 113 +++++++++++++++++++++++ test/routes/discord-routes.test.ts | 25 +++++ 15 files changed, 473 insertions(+), 76 deletions(-) create mode 100644 docs/wiki/Discord-Bridge.md create mode 100644 src/cli-notify.ts create mode 100644 test/cli-notify.test.ts create mode 100644 test/discord-push-glue.test.ts diff --git a/.changeset/discord-bridge.md b/.changeset/discord-bridge.md index 484108c66..27f46a647 100644 --- a/.changeset/discord-bridge.md +++ b/.changeset/discord-bridge.md @@ -2,4 +2,9 @@ 'aicodeman': minor --- -**A two-way Discord bridge: one thread per tab, with replies typed back in.** Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with the tail of what the CLI printed. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so a blocked CLI can be answered from a phone away from the network. A CLI can also ask for the person deliberately with `POST /api/sessions/:id/discord-notify` (a physical step, a blocker, a timed-out watch). Threads are created on the first alert or for every new tab, and are kept, archived or deleted with the tab; pings are off, critical only or every post. The bot token is stored in `discord.json` (0600) and never returned. Setup with least-privilege bot permissions is in `docs/discord-bridge.md`. +**A two-way Discord bridge: answer a blocked agent from your phone.** Opt-in. Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with what is on the tab's screen. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so you can answer from anywhere, with no inbound exposure (it only makes outbound connections). + +- **Agents can ask for you.** `codeman notify "" [--urgent] [--file ]` posts to the tab's thread (`POST /api/sessions/:id/discord-notify`), for a physical step, a blocker or a wait that timed out. Claude is told about it in its system prompt at launch; other CLIs are sent one short message the first time a brand-new tab goes idle (a setting, on while the bridge is on; never to shell tabs). +- **Files both ways.** A file attached in a thread is saved under `/.codeman-discord/` and its path given to the agent; `--file` uploads a file of up to 8 MB from inside the tab's folder. +- **Configurable.** Threads are created on the first alert or for every new tab; a deleted tab's thread is kept, archived or deleted; pings are off, critical only or every post. +- **Safe by default.** The bot token is stored in `discord.json` (0600) and never returned; only allowlisted user IDs can reply; least-privilege bot setup is in `docs/discord-bridge.md`. diff --git a/CLAUDE.md b/CLAUDE.md index f779b90f3..51b0d2fb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session. The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab or a resumed one). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` + **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/README.md b/README.md index 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 # tail output codeman task add "fix the failing test" # (t) queue a task codeman attach # show an attachment card for a local file +codeman notify "" [--urgent] [--file ] # post to this tab's Discord thread (see the Discord bridge) codeman tui --list # numbered session list (plain text when piped) codeman tui 3 # attach to session 3 of that list ``` diff --git a/docs/security-architecture.md b/docs/security-architecture.md index c7b0476ca..153efeb65 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. +- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder, so it cannot post `discord.json` or a key, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. - **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. --- diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 8ca072165..a848e8626 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o | `clis.json` | CLI enable/disable and custom CLI entries. | | `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** | | `webhook.json` | Webhook notification target. | +| `discord.json` | Discord bridge settings. **Holds a bot token.** | | `intents.json` | Read My Mind per-case profiles. | | `linked-cases.json` | Registered cases. | | `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | @@ -188,7 +189,7 @@ that are the same are listed on one line. A file that is not JSON shows only tha a file with more than 200 changes is cut short, so use the files themselves for a full diff. The self-rewritten fields listed under "When a backup is made" are not counted as changes. -**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the +**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json`, `discord.json` (the URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, credential or key is shown as "changed" without its value. URL values lose any `user:password@`, query and fragment. @@ -196,7 +197,7 @@ query and fragment. ## Security - Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, - `push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default + `push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default location exposes nothing the data directory does not already. - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md new file mode 100644 index 000000000..1f99124bc --- /dev/null +++ b/docs/wiki/Discord-Bridge.md @@ -0,0 +1,92 @@ +# Discord Bridge + +Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord +server when a session needs you, gives every tab its own thread, and types your reply in that +thread into that tab. It only makes outbound connections, so nothing is exposed to the internet +and it works when you are away from your network. + +**Opt-in, off by default. One server, one channel, threads per tab.** + +> **Treat the Discord account like a login to the machine.** A reply from an allowed account is +> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password. + +## What it does + +- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop, + each with what is on the tab's screen right now, so you can see what is being asked. +- **One thread per tab** in a parent channel, so a reply always reaches exactly one session. +- **Replies** from an allowed user are typed into that tab as one line plus Enter. +- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can + send a file back. +- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent` + posts to the tab's thread. Every session has the command; see [below](#telling-the-agents). + +## Set it up + +The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token +handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md). +In short: + +1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs. +2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and + invite it with the `bot` scope and only the permissions the guide lists (no Administrator). +3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**, + then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again. + +The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner +only), never in the shared settings, and never shown again once saved. + +## Options + +| Option | Choices | Default | +| ------------------------------ | ---------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell each CLI how to reach you | On or off | On | + +Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a +permission prompt, a question, an error, or a post an agent marked urgent. + +## Telling the agents + +An agent only posts deliberately if it knows it can. With **Tell each CLI how to reach you** on: + +- **Claude** is launched with the instruction as part of its system prompt, so nothing shows in the chat. +- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab goes idle. + Restored tabs, resumed tabs and shell tabs are never sent it. + +Tabs that were already open when you turned the bridge on are not told. The alerts still reach +Discord for them; only the agent-initiated `codeman notify` habit is missing. + +## Files + +- **You to the agent:** attach a file to a reply. It is saved as + `/.codeman-discord/-` and the line the agent sees ends with + `[attached: ]`. At most 5 files of 8 MB each. Add `.codeman-discord/` to your `.gitignore`. +- **Agent to you:** `codeman notify "message" --file ` uploads a file of at most 8 MB that lies + inside the tab's folder. +- Remote (ssh) tabs have no local folder, so they neither send nor receive files. +- A file you send is untrusted content to the agent that opens it. + +## Safety + +- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on. + Everything else is dropped. +- A reply is cut to one printable line (1000 characters) and limited to one per second per tab. +- Posts can ping only the allowlisted users, so agent output cannot ping the whole server. +- A Discord outage or a bad token never affects a session; it logs once and backs off. + +## Troubleshooting + +| Symptom | Check | +| ------------------------------------------------ | -------------------------------------------------------------- | +| Send test says 401 | The token is wrong or was reset. Paste the current one. | +| Send test says the bot cannot see it | Check the bot's role and the channel ID. | +| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. | +| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. | +| A thread is not deleted | The bot lacks Manage Threads on the channel. | + +See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear +from an agent, and [Security](Security) for how secrets are handled. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 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/_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..957e0bc16 --- /dev/null +++ b/src/cli-notify.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify). + * + * A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out + * wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every + * session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to + * print and the exit code instead of exiting, so a test can drive it. + * + * @module cli-notify + */ + +import http from 'node:http'; +import https from 'node:https'; +import { isAbsolute, join } from 'node:path'; +import { readCodemanCredentials } from './codeman-credentials.js'; + +export interface NotifyOptions { + message: string; + urgent?: boolean; + file?: string; + session?: string; + url?: string; +} + +export interface NotifyResult { + code: number; + out?: string; + err?: string; +} + +export type JsonPost = ( + apiUrl: string, + apiPath: string, + payload: unknown +) => Promise<{ status: number; error?: string }>; + +/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */ +export const postJson: JsonPost = (apiUrl, apiPath, payload) => { + const { username, password } = readCodemanCredentials(); + const url = new URL(apiPath, apiUrl); + const body = JSON.stringify(payload); + const transport = url.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }; + if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'POST', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + let text = ''; + res.on('data', (c) => (text += c)); + res.on('end', () => { + let error: string | undefined; + try { + error = (JSON.parse(text) as { error?: string }).error; + } catch { + /* not JSON */ + } + resolve({ status: res.statusCode ?? 0, error }); + }); + } + ); + req.on('error', () => resolve({ status: 0 })); + req.write(body); + req.end(); + }); +}; + +export async function runNotify( + opts: NotifyOptions, + env: NodeJS.ProcessEnv, + cwd: string, + post: JsonPost = postJson +): Promise { + const sessionId = opts.session || env.CODEMAN_SESSION_ID; + const apiUrl = opts.url || env.CODEMAN_API_URL; + if (!sessionId || !apiUrl) { + return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' }; + } + const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message }; + if (opts.urgent) payload.urgent = true; + if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file); + const { status, error } = await post( + apiUrl, + `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, + payload + ); + if (status >= 200 && status < 300) return { code: 0, out: 'Posted to Discord' }; + // The bridge being off is not a failure: the caller just says it in the terminal. + if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' }; + return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` }; +} diff --git a/src/cli.ts b/src/cli.ts index 5faee1ef1..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,53 +93,6 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin }); } -/** POST JSON to the Codeman API with the same credentials `attach` uses. Resolves to the HTTP status, or 0. */ -async function postJson( - apiUrl: string, - apiPath: string, - payload: unknown -): Promise<{ status: number; error?: string }> { - const { username, password } = readCodemanCredentials(); - const url = new URL(apiPath, apiUrl); - const body = JSON.stringify(payload); - const transport = url.protocol === 'https:' ? https : http; - return new Promise((resolve) => { - const headers: Record = { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }; - if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - const req = transport.request( - { - protocol: url.protocol, - hostname: url.hostname, - port: url.port, - method: 'POST', - path: `${url.pathname}${url.search}`, - rejectUnauthorized: false, - headers, - }, - (res) => { - let text = ''; - res.on('data', (c) => (text += c)); - res.on('end', () => { - let error: string | undefined; - try { - error = (JSON.parse(text) as { error?: string }).error; - } catch { - /* not JSON */ - } - resolve({ status: res.statusCode ?? 0, error }); - }); - } - ); - req.on('error', () => resolve({ status: 0 })); - req.write(body); - req.end(); - }); -} - program .command('notify ') .description( @@ -149,30 +103,10 @@ program .option('-s, --session ', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') .option('--url ', 'Codeman API URL (defaults to CODEMAN_API_URL)') .action(async (message: string, options) => { - const sessionId = options.session || process.env.CODEMAN_SESSION_ID; - const apiUrl = options.url || process.env.CODEMAN_API_URL; - if (!sessionId || !apiUrl) { - console.error(palette.err('✗ notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)')); - process.exit(1); - } - const payload: { message: string; urgent?: boolean; file?: string } = { message }; - if (options.urgent) payload.urgent = true; - if (options.file) payload.file = isAbsolute(options.file) ? options.file : join(process.cwd(), options.file); - const { status, error } = await postJson( - apiUrl, - `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, - payload - ); - if (status >= 200 && status < 300) { - console.log(palette.ok('✓ Posted to Discord')); - } else if (status === 409) { - console.log('The Discord bridge is off. Say it in the terminal instead.'); - } else { - console.error( - palette.err(`✗ Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}`) - ); - process.exit(1); - } + const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd()); + if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out); + if (err) console.error(palette.err(`✗ ${err}`)); + process.exit(code); }); program diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index c8e7da36d..f730c8cee 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -46,6 +46,16 @@ export type DiscordStatus = Omit & { lastResult: DiscordResult | null; }; +/** + * A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at + * least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id. + */ +function resolveSessionId(ctx: SessionPort, id: string): string { + if (ctx.sessions.has(id) || id.length < 8) return id; + const hits = [...ctx.sessions.keys()].filter((k) => k.startsWith(id)); + return hits.length === 1 ? hits[0] : id; +} + /** * A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks * followed) lies inside the session's working folder. Anything else is refused, so a CLI cannot be @@ -137,7 +147,8 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe }); app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise> => { - const { id } = req.params as { id: string }; + const { id: rawId } = req.params as { id: string }; + const id = resolveSessionId(deps.ctx, rawId); const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message'); const session = findSessionOrFail(deps.ctx, id, req); let upload: { name: string; data: Uint8Array } | undefined; diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts new file mode 100644 index 000000000..b6b7f7d7f --- /dev/null +++ b/test/cli-notify.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node +/** + * @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a + * recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js'; + +const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' }; + +function fake(status: number, error?: string) { + const post = vi.fn(async () => ({ status, error })); + return post; +} + +describe('runNotify', () => { + it('refuses outside a Codeman session, before any request', async () => { + const post = fake(200); + const r = await runNotify({ message: 'hi' }, {}, '/work', post); + expect(r.code).toBe(1); + expect(r.err).toMatch(/inside a Codeman session/); + expect(post).not.toHaveBeenCalled(); + }); + + it('posts the message to this session with the session id URL-encoded', async () => { + const post = fake(200); + const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post); + expect(r).toEqual({ code: 0, out: 'Posted to Discord' }); + expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', { + message: 'press the reset button', + }); + }); + + it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => { + const post = fake(200); + await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post); + expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' }); + await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post); + expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' }); + }); + + it('lets --session and --url override the environment', async () => { + const post = fake(200); + await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post); + expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']); + }); + + it('treats a disabled bridge (409) as success that says so', async () => { + const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409)); + expect(r.code).toBe(0); + expect(r.out).toMatch(/bridge is off/); + }); + + it('fails with the server error, or the status, or a plain message when nothing answered', async () => { + expect( + (await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err + ).toBe('Could not post to Discord: The file must be inside the session folder'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)'); + expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord'); + }); +}); + +describe('postJson (real transport)', () => { + let server: Server; + afterEach(() => new Promise((resolve) => server.close(() => resolve()))); + + async function serve(status: number, reply: unknown) { + const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' }; + server = createServer((req, res) => { + req.on('data', (c) => (seen.body += c)); + req.on('end', () => { + seen.method = req.method; + seen.url = req.url; + seen.type = req.headers['content-type']; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reply)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; + } + + it('sends a JSON POST and reports the status and the server error text', async () => { + const { seen, url } = await serve(400, { success: false, error: 'nope' }); + expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({ + status: 400, + error: 'nope', + }); + expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' }); + expect(JSON.parse(seen.body)).toEqual({ message: 'hi' }); + }); + + it('answers status 0 when nothing is listening', async () => { + const { url } = await serve(200, {}); + await new Promise((resolve) => server.close(() => resolve())); + expect((await postJson(url, '/x', {})).status).toBe(0); + server = createServer().listen(0); // so afterEach has something to close + }); +}); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts new file mode 100644 index 000000000..4c77b4bb2 --- /dev/null +++ b/test/discord-push-glue.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +/** + * @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no + * Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by + * kind. The bridge and routes are tested on their own; this pins the lines that make the feature + * exist. A real WebServer, never started, with the bridge replaced by a recorder. + * + * Port: N/A (no server start). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) })); +vi.mock('web-push', () => ({ + default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() }, +})); + +import { WebServer } from '../src/web/server.js'; +import { SseEvent } from '../src/web/sse-events.js'; + +type Internals = { + pushStore: { getAll: () => never[] }; + discordBridge: Record>; + sessions: Map; + mux: { capturePaneText?: (name: string) => string | null }; + sendPushNotifications: (event: string, data: Record) => Promise; + broadcast: (event: string, data: unknown) => void; +}; + +function server(): Internals { + const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; + s.pushStore = { getAll: () => [] }; + s.discordBridge = { + notify: vi.fn(async () => null), + sessionCreated: vi.fn(async () => undefined), + sessionRenamed: vi.fn(async () => undefined), + sessionDeleted: vi.fn(async () => undefined), + sessionIdle: vi.fn(async () => undefined), + }; + return s; +} + +beforeEach(() => sendNotification.mockClear()); + +describe('sendPushNotifications -> Discord', () => { + it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => { + const s = server(); + s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' }); + s.mux = { capturePaneText: () => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n' }; + await s.sendPushNotifications('hook:permission_prompt', { + sessionId: 's1', + sessionName: 'w1-app', + tool_name: 'Bash', + }); + expect(s.discordBridge.notify).toHaveBeenCalledTimes(1); + expect(s.discordBridge.notify.mock.calls[0][0]).toMatchObject({ + event: 'hook:permission_prompt', + title: 'Permission Required', + body: '[w1-app] Tool: Bash', + urgency: 'critical', + sessionId: 's1', + sessionName: 'w1-app', + tail: 'Which option?\n 1. Cap GPUs\n 2. Cap CPU', + }); + expect(sendNotification).not.toHaveBeenCalled(); + }); + + it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => { + const s = server(); + s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' }); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' }); + expect(s.discordBridge.notify.mock.calls[0][0].tail).toBe('plain answer'); + await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' }); + expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined(); + }); + + it('sends nothing for an event Web Push does not carry', async () => { + const s = server(); + await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' }); + expect(s.discordBridge.notify).not.toHaveBeenCalled(); + }); +}); + +describe('broadcast -> Discord tab lifecycle', () => { + it('routes created, updated and deleted by kind, reading both payload shapes', () => { + const s = server(); + s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } }); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123); + s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); + expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); + s.broadcast(SseEvent.SessionDeleted, { id: 's1' }); + expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('s1'); + }); + + it('ignores an event with no session id', () => { + const s = server(); + s.broadcast(SseEvent.SessionDeleted, {}); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + }); + + it('tells the bridge whether an idle session is an external CLI: codex yes, claude and shell no', () => { + const s = server(); + s.sessions.set('cx', { mode: 'codex' }); + s.sessions.set('cl', { mode: 'claude' }); + s.sessions.set('sh', { mode: 'shell' }); + for (const id of ['cx', 'cl', 'sh', 'missing']) s.broadcast(SseEvent.SessionIdle, { id }); + expect(s.discordBridge.sessionIdle.mock.calls).toEqual([ + ['cx', true], + ['cl', false], + ['sh', false], + ['missing', false], + ]); + }); +}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 5befb8e28..19f42846e 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -122,6 +122,31 @@ describe('POST /api/sessions/:id/discord-notify', () => { bridge.stop(); }); + it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => { + const { app, bridge, ctx } = await harness(); + await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL }); + const ok = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ok.statusCode).toBe(200); + ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!); + const ambiguous = await app.inject({ + method: 'POST', + url: '/api/sessions/test-ses/discord-notify', + payload: { message: 'hi' }, + }); + expect(ambiguous.statusCode).toBe(404); + const short = await app.inject({ + method: 'POST', + url: '/api/sessions/test/discord-notify', + payload: { message: 'hi' }, + }); + expect(short.statusCode).toBe(404); + bridge.stop(); + }); + it('rejects an empty or oversized message', async () => { const { app } = await harness(); const url = `/api/sessions/test-session-1/discord-notify`; From aedefab8b471df60c07e35ab6b2664ef31f95632 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:58:16 +0800 Subject: [PATCH 06/24] chore(discord): drop the changeset, the maintainer writes release notes --- .changeset/discord-bridge.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .changeset/discord-bridge.md diff --git a/.changeset/discord-bridge.md b/.changeset/discord-bridge.md deleted file mode 100644 index 27f46a647..000000000 --- a/.changeset/discord-bridge.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'aicodeman': minor ---- - -**A two-way Discord bridge: answer a blocked agent from your phone.** Opt-in. Codeman posts to a private Discord channel when a session asks for permission, asks a question, goes idle or errors, in a thread of its own for that tab, with what is on the tab's screen. A reply in the thread from an allowed Discord user is typed into that tab as one line plus Enter, so you can answer from anywhere, with no inbound exposure (it only makes outbound connections). - -- **Agents can ask for you.** `codeman notify "" [--urgent] [--file ]` posts to the tab's thread (`POST /api/sessions/:id/discord-notify`), for a physical step, a blocker or a wait that timed out. Claude is told about it in its system prompt at launch; other CLIs are sent one short message the first time a brand-new tab goes idle (a setting, on while the bridge is on; never to shell tabs). -- **Files both ways.** A file attached in a thread is saved under `/.codeman-discord/` and its path given to the agent; `--file` uploads a file of up to 8 MB from inside the tab's folder. -- **Configurable.** Threads are created on the first alert or for every new tab; a deleted tab's thread is kept, archived or deleted; pings are off, critical only or every post. -- **Safe by default.** The bot token is stored in `discord.json` (0600) and never returned; only allowlisted user IDs can reply; least-privilege bot setup is in `docs/discord-bridge.md`. From eb0553a645f58ee503ac3f723a8bfa462458bd38 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:03:26 +0800 Subject: [PATCH 07/24] fix(discord): address review on #741 Builds the alert tail only for a post that goes out; separates deduped from disabled and never dedupes a deliberate notify; types the startup hint with fromUser false and skips resumed, remote and Docker tabs; clears per-tab maps and closes the bridge on stop; acts only on a real delete, not a detach; serialises thread-map writes; reloads the bridge after a discord.json restore; probes and self-ignores .codeman-discord; validates the message id; reacts when a reply is rate limited; documents what leaves the machine. --- CLAUDE.md | 4 +- docs/discord-bridge.md | 4 +- docs/security-architecture.md | 2 +- docs/wiki/Discord-Bridge.md | 2 +- src/discord-bridge.ts | 122 ++++++++++++++--- src/web/routes/config-backup-routes.ts | 9 +- src/web/routes/discord-routes.ts | 3 +- src/web/server.ts | 63 +++++---- test/discord-bridge.test.ts | 158 +++++++++++++++++++++-- test/discord-push-glue.test.ts | 56 ++++++-- test/routes/config-backup-routes.test.ts | 18 ++- test/routes/discord-routes.test.ts | 14 ++ 12 files changed, 380 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 51b0d2fb0..57b45d6a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session. The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab or a resumed one). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the 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 is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, a resumed one, 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`. @@ -191,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. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index ab5cb8c0e..71f0b5636 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -101,14 +101,14 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push A CLI only posts deliberately if it knows it can. While the bridge and `startupHint` are on: - **Claude** is launched with `--append-system-prompt`, so nothing shows in the conversation. -- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs and shell tabs are never sent it. +- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs, 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 already on the PATH of every session 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. Add `.codeman-discord/` to your `.gitignore`. 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, so a CLI cannot post `~/.codeman/discord.json` or an ssh key. Remote tabs cannot send files. +- **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 diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 153efeb65..da6306dbd 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **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, so it cannot post `discord.json` or a key, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt, or typed once to a brand-new external CLI tab, never to a shell. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA. +- **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:** each alert sends the rendered screen (whatever the agent printed) to Discord, and with **Which events** set to Everything that is every turn end; 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 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/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 1f99124bc..8899bf3fd 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -55,7 +55,7 @@ An agent only posts deliberately if it knows it can. With **Tell each CLI how to - **Claude** is launched with the instruction as part of its system prompt, so nothing shows in the chat. - **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab goes idle. - Restored tabs, resumed tabs and shell tabs are never sent it. + Restored tabs, resumed 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. diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index f548359f1..571931c70 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -22,6 +22,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { join } from 'node:path'; +import { probePathKind } from './utils/bounded-path-probe.js'; import WebSocket from 'ws'; import { WEBHOOK_SCOPES, type WebhookScope, type WebhookUrgency } from './types/push.js'; @@ -85,7 +86,7 @@ export interface DiscordMessage { sessionId?: string; sessionName?: string; /** The tail of what the CLI last printed, so the reader can see what it is asking. */ - tail?: string; + tail?: string | (() => string); host?: string; /** A file to upload with the post (already read and size-checked by the caller). */ file?: DiscordFile; @@ -98,6 +99,8 @@ export interface DiscordFile { 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; @@ -227,7 +230,14 @@ export function buildPost(msg: DiscordMessage, cfg: DiscordConfig, explicitUrgen 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)}**`; - const fence = msg.tail?.trim() ? `\n\`\`\`\n${msg.tail.trim().replace(/```/g, "'''")}\n\`\`\`` : ''; + // 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. @@ -579,11 +589,48 @@ export class DiscordGateway { // 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` (already ending in Enter) into the session. False when the session is gone. */ - sendInput: (sessionId: string, text: string) => Promise<boolean>; + /** `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>; /** A session's working folder, where received files are saved. Undefined when it is gone or remote. */ sessionDir?: (sessionId: string) => string | undefined; socketFactory?: SocketFactory; @@ -604,6 +651,8 @@ export class DiscordBridge { /** 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 | null>>(); + /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ + private persistChain: Promise<void> = Promise.resolve(); private last: DiscordResult | null = null; private readonly api: DiscordApi; private readonly now: () => number; @@ -648,6 +697,10 @@ export class DiscordBridge { this.gateway?.stop(); this.gateway = null; activeStartupHint = undefined; + this.lastSent.clear(); + this.lastReply.clear(); + this.names.clear(); + this.fresh.clear(); } private fail(err: unknown): DiscordResult { @@ -668,6 +721,13 @@ export class DiscordBridge { return { ok: false, status, error, at: this.now() }; } + private persistThreads(): Promise<void> { + this.persistChain = this.persistChain + .then(() => writeThreadMap(this.deps.configDir, { ...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 | null> { const existing = this.threads[sessionId]; @@ -679,7 +739,7 @@ export class DiscordBridge { const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); if (!t?.id) return null; this.threads[sessionId] = t.id; - await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + await this.persistThreads(); return t.id; } catch (err) { this.last = this.fail(err); @@ -693,13 +753,21 @@ export class DiscordBridge { } /** Post an alert. Session-less messages go to the parent channel. Never throws. */ - async notify(msg: DiscordMessage, explicitUrgent = false): Promise<DiscordResult | null> { + async notify( + msg: DiscordMessage, + opts: { urgent?: boolean; deliberate?: boolean } = {} + ): Promise<DiscordResult | null> { + const explicitUrgent = opts.urgent === true; if (!this.loaded) await this.reload(); if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; const key = `${msg.event}:${msg.sessionId ?? ''}`; const t = this.now(); const prev = this.lastSent.get(key); - if (!explicitUrgent && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return null; + // 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); @@ -759,10 +827,16 @@ export class DiscordBridge { } /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ - async sessionCreated(sessionId: string, name: string | undefined, createdAt?: number): Promise<void> { + async sessionCreated( + sessionId: string, + name: string | undefined, + createdAt?: number, + resumed = false + ): Promise<void> { if (!this.loaded) await this.reload(); // 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); + // A resumed tab is created now too, but its conversation is not new, so it is never hinted. + if (!resumed && createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; await this.threadFor(sessionId, name); } @@ -771,11 +845,12 @@ export class DiscordBridge { * 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, external: boolean): Promise<void> { + async sessionIdle(sessionId: string, hintable: boolean): Promise<void> { if (!this.fresh.delete(sessionId)) return; - if (!external || !this.cfg.enabled || !this.cfg.startupHint) return; + if (!hintable || !this.cfg.enabled || !this.cfg.startupHint) return; const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`); - if (text) await this.deps.sendInput(sessionId, text).catch(() => false); + // 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> { @@ -788,12 +863,14 @@ export class DiscordBridge { /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ async sessionDeleted(sessionId: string): Promise<void> { + // Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server. + this.lastReply.delete(sessionId); + this.names.delete(sessionId); + this.fresh.delete(sessionId); const thread = this.threads[sessionId]; if (!thread) return; delete this.threads[sessionId]; - this.lastReply.delete(sessionId); - this.names.delete(sessionId); - await writeThreadMap(this.deps.configDir, this.threads).catch(() => undefined); + await this.persistThreads(); 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); @@ -813,7 +890,10 @@ export class DiscordBridge { 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.' }; - const target = join(dir, RECEIVED_DIR); + // 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()) { @@ -832,9 +912,9 @@ export class DiscordBridge { skipped.push(name); continue; } - await fs.mkdir(target, { recursive: true }); const dest = join(target, `${m.id}-${name}`); - await fs.writeFile(dest, buf, { mode: 0o600 }); + // '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); @@ -857,7 +937,11 @@ export class DiscordBridge { if (!sessionId) return; const t = this.now(); const prev = this.lastReply.get(sessionId); - if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) return; + 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; + } this.lastReply.set(sessionId, t); const saved = await this.saveAttachments(sessionId, m); const text = sanitiseReply(m.content ?? '', saved.paths); @@ -866,7 +950,7 @@ export class DiscordBridge { return; } if (saved.refused) await this.say(m.channel_id, saved.refused); - const ok = await this.deps.sendInput(sessionId, text).catch(() => false); + const ok = await this.deps.sendInput(sessionId, text, true).catch(() => false); const reply = ok ? this.api.react(m.channel_id, m.id, '✅') : this.api.post(m.channel_id, { 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 index f730c8cee..96b62e4e5 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -171,7 +171,8 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe host: deps.hostTitle(), file: upload, }, - urgent === true + // 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); diff --git a/src/web/server.ts b/src/web/server.ts index b7b092ce0..42ea4967a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -512,11 +512,13 @@ export class WebServer extends EventEmitter { private discordBridge = new DiscordBridge({ configDir: getDataDir(), fetchImpl: webviewFetch, - sendInput: async (sessionId, text) => { + sendInput: async (sessionId, text, fromUser) => { const session = this.sessions.get(sessionId); if (!session) return false; - if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true; - return session.write(text, { fromUser: true }); + // 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 }); }, // Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go. sessionDir: (sessionId) => { @@ -1342,7 +1344,7 @@ 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(), @@ -1792,7 +1794,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'); @@ -2727,41 +2733,42 @@ export class WebServer extends EventEmitter { this.bridgeSessionEvent(event, data); } - private isExternalSession(sessionId: string): boolean { + /** + * 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); + 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 { - if ( - event !== SseEvent.SessionCreated && - event !== SseEvent.SessionUpdated && - event !== SseEvent.SessionDeleted && - event !== SseEvent.SessionIdle - ) { + // 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 }; + resumeSessionId?: string; + session?: { id?: string; name?: string; createdAt?: number; resumeSessionId?: string }; }; const s = d.session ?? d; if (!s.id) return; const id = s.id; const run = - event === SseEvent.SessionDeleted - ? this.discordBridge.sessionDeleted(id) - : event === SseEvent.SessionIdle - ? // Claude is told at launch; a shell must never be typed at. - this.discordBridge.sessionIdle(id, this.isExternalSession(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); + 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, !!s.resumeSessionId) + .then(() => this.discordBridge.sessionRenamed(id, s.name)) + : this.discordBridge.sessionRenamed(id, s.name); void run.catch(() => undefined); } @@ -3009,7 +3016,8 @@ export class WebServer extends EventEmitter { urgency: template.urgency as WebhookUrgency, sessionId: sessionId || undefined, sessionName: sessionName || undefined, - tail: dcSession ? this.discordTail(dcSession) : 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) : undefined, host: this.windowTitle, }) .catch(() => undefined); @@ -3830,6 +3838,8 @@ export class WebServer extends EventEmitter { } catch (err) { console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); } + // A half-built session never had a thread, but its per-tab bridge state goes with it. + void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined); // 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 }); @@ -4402,6 +4412,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/discord-bridge.test.ts b/test/discord-bridge.test.ts index f7143ca79..57a4de71c 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -3,13 +3,14 @@ * @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 { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +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 { buildPost, DISCORD_STARTUP_HINT, + ensureReceivedDir, isDiscordCdnUrl, safeAttachmentName, screenTail, @@ -261,7 +262,7 @@ describe('DiscordGateway', () => { describe('DiscordBridge', () => { let posts: Array<{ path: string; method: string; body: Record<string, unknown> | undefined }>; - let sendInput: ReturnType<typeof vi.fn<(id: string, text: string) => Promise<boolean>>>; + let sendInput: ReturnType<typeof vi.fn<(id: string, text: string, fromUser: boolean) => Promise<boolean>>>; let t: number; function bridge(overrides: Partial<DiscordConfig> = {}, fetchOverride?: DiscordFetch) { @@ -308,16 +309,65 @@ describe('DiscordBridge', () => { b.stop(); }); - it('dedupes the same event within 3 s but lets an explicit urgent post through', async () => { + it('dedupes an automatic event within 3 s and says so, which is not the "bridge is off" answer', async () => { const b = bridge(); - await b.notify(MSG); - await b.notify(MSG); + 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); - await b.notify(MSG, true); + 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(); @@ -377,7 +427,7 @@ describe('DiscordBridge', () => { 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'); + 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(); @@ -402,7 +452,9 @@ describe('DiscordBridge', () => { await b.onGatewayMessage(reply({ content: 'a\nb' })); await b.onGatewayMessage(reply({ content: 'again' })); expect(sendInput).toHaveBeenCalledTimes(1); - expect(sendInput).toHaveBeenCalledWith('s1', 'a b\r'); + 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(); }); @@ -416,6 +468,35 @@ describe('DiscordBridge', () => { }); }); + 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(); @@ -519,7 +600,7 @@ describe('files', () => { describe('DiscordBridge files and startup hint', () => { const reply = (over: Record<string, unknown> = {}) => ({ - id: 'm1', + id: '555555555555555555', channel_id: THREAD, guild_id: GUILD, content: '', @@ -530,7 +611,7 @@ describe('DiscordBridge files and startup hint', () => { ...over, }); let work: string; - let sent: Array<[string, string]>; + let sent: Array<[string, string, boolean]>; let posted: Array<{ path: string; body?: Record<string, unknown> }>; let downloads: string[]; @@ -547,7 +628,7 @@ describe('DiscordBridge files and startup hint', () => { 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) => (sent.push([id, text]), true), + sendInput: async (id, text, fromUser) => (sent.push([id, text, fromUser]), true), sessionDir: dirFor, socketFactory: () => new FakeSocket(), now: () => t, @@ -573,10 +654,10 @@ describe('DiscordBridge files and startup hint', () => { 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', 'm1-shot_one.png'); + 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`]]); + expect(sent).toEqual([['s1', `[attached: ${dest}]\r`, true]]); b.stop(); }); @@ -627,10 +708,28 @@ describe('DiscordBridge files and startup hint', () => { 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('does not hint a resumed tab, even though it was created just now', async () => { + const b = await make(); + await b.sessionCreated('s6', 'resumed', t - 1000, true); + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(0); + 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('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 @@ -684,3 +783,36 @@ describe('screenTail', () => { 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 index 4c77b4bb2..04c18ba53 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -24,6 +24,7 @@ type Internals = { mux: { capturePaneText?: (name: string) => string | null }; sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>; broadcast: (event: string, data: unknown) => void; + cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>; }; function server(): Internals { @@ -35,6 +36,7 @@ function server(): Internals { sessionRenamed: vi.fn(async () => undefined), sessionDeleted: vi.fn(async () => undefined), sessionIdle: vi.fn(async () => undefined), + stop: vi.fn(), }; return s; } @@ -45,22 +47,28 @@ describe('sendPushNotifications -> Discord', () => { it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => { const s = server(); s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' }); - s.mux = { capturePaneText: () => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n' }; + 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); - expect(s.discordBridge.notify.mock.calls[0][0]).toMatchObject({ + 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', - tail: 'Which option?\n 1. Cap GPUs\n 2. Cap CPU', }); + // 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(); }); @@ -68,7 +76,7 @@ describe('sendPushNotifications -> Discord', () => { 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'); + 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(); }); @@ -87,27 +95,57 @@ describe('broadcast -> Discord tab lifecycle', () => { 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).toHaveBeenCalledWith('s1'); + expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); }); - it('ignores an event with no session id', () => { + it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => { const s = server(); - s.broadcast(SseEvent.SessionDeleted, {}); + await s.cleanupSession('gone', false); expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled(); + await s.cleanupSession('gone', true); + expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone'); }); - it('tells the bridge whether an idle session is an external CLI: codex yes, claude and shell no', () => { + it('marks a resumed tab as resumed, so it is never sent the typed hint', () => { + const s = server(); + s.broadcast(SseEvent.SessionCreated, { session: { id: 's9', name: 'w9', createdAt: 5, resumeSessionId: 'abc' } }); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s9', 'w9', 5, true); + s.broadcast(SseEvent.SessionCreated, { session: { id: 's8', name: 'w8', createdAt: 6 } }); + expect(s.discordBridge.sessionCreated).toHaveBeenLastCalledWith('s8', 'w8', 6, false); + }); + + 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' }); - for (const id of ['cx', 'cl', 'sh', 'missing']) s.broadcast(SseEvent.SessionIdle, { id }); + 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/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-routes.test.ts b/test/routes/discord-routes.test.ts index 19f42846e..14efe6003 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -109,6 +109,7 @@ describe('/api/discord', () => { }); 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`; @@ -147,6 +148,19 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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('rejects an empty or oversized message', async () => { const { app } = await harness(); const url = `/api/sessions/test-session-1/discord-notify`; From e95d38a9442796e740f2dc2ce15ab79b58139d1f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:04:20 +0800 Subject: [PATCH 08/24] fix(discord): load the thread map once, so alerts that race at startup keep their threads --- src/discord-bridge.ts | 22 +++++++++++++++++----- test/discord-push-glue.test.ts | 6 +++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 571931c70..db2d2a94d 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -653,6 +653,9 @@ export class DiscordBridge { private pending = new Map<string, Promise<string | null>>(); /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ private persistChain: Promise<void> = Promise.resolve(); + private mapLoad?: Promise<void>; + /** 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; @@ -672,13 +675,22 @@ export class DiscordBridge { return this.gateway?.lastError ?? null; } + private async ensureLoaded(): Promise<void> { + if (this.loaded) return; + this.firstLoad ??= this.reload(); + await this.firstLoad; + } + /** (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); - if (!this.loaded) { - this.threads = await readThreadMap(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 ??= readThreadMap(this.deps.configDir).then((saved) => { + this.threads = { ...saved, ...this.threads }; this.loaded = true; - } + }); + await this.mapLoad; this.gateway?.stop(); this.gateway = null; activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; @@ -758,7 +770,7 @@ export class DiscordBridge { opts: { urgent?: boolean; deliberate?: boolean } = {} ): Promise<DiscordResult | null> { const explicitUrgent = opts.urgent === true; - if (!this.loaded) await this.reload(); + await this.ensureLoaded(); if (!shouldSendDiscord(this.cfg, msg.urgency)) return null; const key = `${msg.event}:${msg.sessionId ?? ''}`; const t = this.now(); @@ -833,7 +845,7 @@ export class DiscordBridge { createdAt?: number, resumed = false ): Promise<void> { - if (!this.loaded) await this.reload(); + await this.ensureLoaded(); // Only a tab made just now: the same event also fires for sessions restored at server start. // A resumed tab is created now too, but its conversation is not new, so it is never hinted. if (!resumed && createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 04c18ba53..74220a84e 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -18,7 +18,7 @@ import { WebServer } from '../src/web/server.js'; import { SseEvent } from '../src/web/sse-events.js'; type Internals = { - pushStore: { getAll: () => never[] }; + pushStore: { getAll: () => never[]; dispose: () => void }; discordBridge: Record<string, ReturnType<typeof vi.fn>>; sessions: Map<string, unknown>; mux: { capturePaneText?: (name: string) => string | null }; @@ -29,7 +29,7 @@ type Internals = { function server(): Internals { const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals; - s.pushStore = { getAll: () => [] }; + s.pushStore = { getAll: () => [], dispose: () => undefined }; s.discordBridge = { notify: vi.fn(async () => null), sessionCreated: vi.fn(async () => undefined), @@ -92,7 +92,7 @@ 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); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123, false); s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); }); From 6d4357502b79df646cd0e7e7714fd8114d673a77 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:38:05 +0800 Subject: [PATCH 09/24] feat(discord): make the typed first-message hint a separate opt-in, off by default Claude keeps the silent system-prompt hint (startupHint, on). Other CLIs are sent the typed message only with typedHint, which costs a model turn per tab. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/discord-bridge.md | 21 +++++++++++---------- docs/wiki/Discord-Bridge.md | 21 +++++++++++---------- src/discord-bridge.ts | 9 ++++++++- src/web/public/index.html | 11 +++++++++-- src/web/public/settings-ui.js | 2 ++ src/web/schemas.ts | 1 + test/discord-bridge.test.ts | 22 ++++++++++++++++++++-- test/routes/discord-routes.test.ts | 13 +++++++++++++ 10 files changed, 77 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 57b45d6a7..505d511b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.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 is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, a resumed one, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "<message>" [--urgent] [--file <path>]` 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` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.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, a resumed one, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "<message>" [--urgent] [--file <path>]` 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`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9ceca0d0c..9216522ad 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1493,7 +1493,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | 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?, pingMode?, scope?, startupHint? }` (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. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, 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. `409` while the bridge is disabled. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 71f0b5636..f257e0a2c 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -88,20 +88,21 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push ## Options -| Option | Values | Default | Effect | -| ----------------- | --------------------------- | ----------- | ---------------------------------------------------------------------------------------- | -| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | -| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | -| `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 each CLI about `codeman notify` (Claude at launch, others once on their first idle) | +| 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 | +| `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 and `startupHint` are on: +A CLI only posts deliberately if it knows it can. While the bridge is on: -- **Claude** is launched with `--append-system-prompt`, so nothing shows in the conversation. -- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. +- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. +- **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, resumed tabs, 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 already on the PATH of every session 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. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 8899bf3fd..2e5c94cea 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -38,23 +38,24 @@ only), never in the shared settings, and never shown again once saved. ## Options -| Option | Choices | Default | -| ------------------------------ | ---------------------------------------- | --------------- | -| Create threads | On the first alert, or for every new tab | First alert | -| When a tab is deleted | Keep, archive or delete the thread | Archive | -| @ ping | Never, critical events only, every post | Critical only | -| Which events | Needs attention, or everything | Needs attention | -| Tell each CLI how to reach you | On or off | On | +| Option | Choices | Default | +| ---------------------------------- | ---------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell Claude how to reach you | On or off | On | +| Also tell other CLIs, as a message | On or off | Off | Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a permission prompt, a question, an error, or a post an agent marked urgent. ## Telling the agents -An agent only posts deliberately if it knows it can. With **Tell each CLI how to reach you** on: +An agent only posts deliberately if it knows it can: -- **Claude** is launched with the instruction as part of its system prompt, so nothing shows in the chat. -- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab goes idle. +- **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. +- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a 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, resumed 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 diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index db2d2a94d..4ee460162 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -63,6 +63,11 @@ export interface DiscordConfig { 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 const DEFAULT_DISCORD_CONFIG: DiscordConfig = { @@ -76,6 +81,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { pingMode: 'critical', scope: 'attention', startupHint: true, + typedHint: false, }; export interface DiscordMessage { @@ -271,6 +277,7 @@ function coerce(raw: unknown): DiscordConfig { 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, }; } @@ -859,7 +866,7 @@ export class DiscordBridge { */ async sessionIdle(sessionId: string, hintable: boolean): Promise<void> { if (!this.fresh.delete(sessionId)) return; - if (!hintable || !this.cfg.enabled || !this.cfg.startupHint) return; + if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return; 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); diff --git a/src/web/public/index.html b/src/web/public/index.html index 2bf3dda30..5a00822dd 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3288,11 +3288,18 @@ <h2>Notifications</h2> </div> <div class="set-row"> <div class="set-row-text"> - <span class="set-row-label">Tell each CLI how to reach you</span> - <span class="set-row-desc">Claude is told at launch. Other CLIs are sent one short message when a new tab first becomes idle. It explains the codeman notify command, for when a CLI is blocked on something only you can do. Never sent to shell tabs.</span> + <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.</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, resumed, 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> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 3e76fd86c..d1311e476 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1770,6 +1770,7 @@ Object.assign(CodemanApp.prototype, { 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'}` }; @@ -1794,6 +1795,7 @@ Object.assign(CodemanApp.prototype, { 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) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e012c8189..5b4b3f096 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2232,6 +2232,7 @@ export const DiscordUpdateSchema = z pingMode: z.enum(DISCORD_PING_MODES).optional(), scope: z.enum(WEBHOOK_SCOPES).optional(), startupHint: z.boolean().optional(), + typedHint: z.boolean().optional(), }) .strict(); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 57a4de71c..25eb08792 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -3,7 +3,16 @@ * @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 { + 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'; @@ -634,7 +643,7 @@ describe('DiscordBridge files and startup hint', () => { now: () => t, sleep: async () => undefined, }); - return writeDiscordConfig(dir, { ...CFG, ...cfg }).then(async () => { + return writeDiscordConfig(dir, { ...CFG, typedHint: true, ...cfg }).then(async () => { await b.reload(); await b.notify(MSG); posted.length = 0; @@ -730,6 +739,15 @@ describe('DiscordBridge files and startup hint', () => { 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 diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 14efe6003..c4c33ff7e 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -79,6 +79,19 @@ describe('/api/discord', () => { 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' } }); From 2d4036cf1ec9b9e7ef426f8b7564eda6c5b9d760 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:50:24 +0800 Subject: [PATCH 10/24] test(discord): give the bare cleanup server the bridge its delete path now calls --- test/agent-inbox-server-wiring.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index a0987815c..41963b738 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 () => {} }, broadcast: () => {}, }); return server; From 0db9b55961f94a70d1c15aaf533384523c0c6678 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:47:25 +0800 Subject: [PATCH 11/24] fix(discord): rate limit discord-notify, bound the folder probe, stop sendTest swapping the live config - discord-notify: per-session token bucket (6 a minute), 429 RATE_LIMITED, plain line from codeman notify - fileProblem probes the working folder with the bounded probe before realpath - sendTest uses its own DiscordApi and leaves this.cfg alone - drop the resumed-tab check from the typed hint (only Claude's field was read) - first sessionRenamed per session records the name without a PATCH - boot goes through ensureLoaded, so one gateway starts - a thread created without an id reports the failure --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/discord-bridge.md | 4 +-- docs/wiki/Discord-Bridge.md | 2 +- src/cli-notify.ts | 2 ++ src/discord-bridge.ts | 29 ++++++++++----------- src/web/public/index.html | 2 +- src/web/routes/discord-routes.ts | 36 ++++++++++++++++++++++++++ src/web/server.ts | 7 +++-- test/cli-notify.test.ts | 1 + test/discord-bridge.test.ts | 41 ++++++++++++++++++++++++------ test/discord-push-glue.test.ts | 10 +------- test/routes/discord-routes.test.ts | 29 ++++++++++++++++++++- 13 files changed, 123 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 505d511b6..b47e846a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.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, a resumed one, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "<message>" [--urgent] [--file <path>]` 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` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited, 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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9216522ad..52f2929e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1495,7 +1495,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | `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?, 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. `409` while the bridge is disabled. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | +| `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. `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, 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)). diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index f257e0a2c..5e29c3cc4 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -10,7 +10,7 @@ Codeman can post to a private Discord server when a session needs you, give ever - Posts when a session asks for permission, asks a question, goes idle, stops or errors. - 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. -- 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`. +- 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)). @@ -102,7 +102,7 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push 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. -- **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, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. +- **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 already on the PATH of every session 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. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 2e5c94cea..79ff70012 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -56,7 +56,7 @@ 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. - **Every other CLI** is told only if you turn on **Also tell other CLIs, as a 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, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. + 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. diff --git a/src/cli-notify.ts b/src/cli-notify.ts index 957e0bc16..d14f102f8 100644 --- a/src/cli-notify.ts +++ b/src/cli-notify.ts @@ -99,5 +99,7 @@ export async function runNotify( if (status >= 200 && status < 300) return { code: 0, out: 'Posted to Discord' }; // The bridge being off is not a failure: the caller just says it in the terminal. if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' }; + 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/discord-bridge.ts b/src/discord-bridge.ts index 4ee460162..c6e652683 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -682,7 +682,7 @@ export class DiscordBridge { return this.gateway?.lastError ?? null; } - private async ensureLoaded(): Promise<void> { + async ensureLoaded(): Promise<void> { if (this.loaded) return; this.firstLoad ??= this.reload(); await this.firstLoad; @@ -756,7 +756,10 @@ export class DiscordBridge { const p = (async () => { try { const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); - if (!t?.id) return null; + if (!t?.id) { + this.last = this.fail(new Error('Discord did not return a thread')); + return null; + } this.threads[sessionId] = t.id; await this.persistThreads(); return t.id; @@ -821,10 +824,10 @@ export class DiscordBridge { /** A deliberate test send to the parent channel: bypasses `enabled`, scope and dedupe. */ async sendTest(cfg: DiscordConfig, host?: string): Promise<DiscordResult> { - const saved = this.cfg; - this.cfg = cfg; + // 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 this.api.post( + await api.post( cfg.parentChannelId, buildPost( { @@ -840,22 +843,14 @@ export class DiscordBridge { return (this.last = { ok: true, at: this.now() }); } catch (err) { return (this.last = this.fail(err)); - } finally { - this.cfg = saved; } } /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */ - async sessionCreated( - sessionId: string, - name: string | undefined, - createdAt?: number, - resumed = false - ): Promise<void> { + 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. - // A resumed tab is created now too, but its conversation is not new, so it is never hinted. - if (!resumed && createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); + if (createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId); if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return; await this.threadFor(sessionId, name); } @@ -873,8 +868,10 @@ export class DiscordBridge { } async sessionRenamed(sessionId: string, name: string | undefined): Promise<void> { - if (this.names.get(sessionId) === (name ?? '')) return; + 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); diff --git a/src/web/public/index.html b/src/web/public/index.html index 5a00822dd..fee13f87a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3296,7 +3296,7 @@ <h2>Notifications</h2> <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, resumed, remote or Docker tabs. Off by default.</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> diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 96b62e4e5..42b63a7a3 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -18,6 +18,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } import { findSessionOrFail, 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, @@ -56,6 +57,29 @@ function resolveSessionId(ctx: SessionPort, id: string): string { return hits.length === 1 ? hits[0] : 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; +} + +/** 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 @@ -68,6 +92,10 @@ async function fileProblem( ): 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 }); + } try { const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]); const rel = relative(root, real); @@ -151,6 +179,14 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe const id = resolveSessionId(deps.ctx, 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); diff --git a/src/web/server.ts b/src/web/server.ts index 42ea4967a..8b913e3d9 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1356,7 +1356,7 @@ export class WebServer extends EventEmitter { ctx, hostTitle: () => this.windowTitle, }); - if (!this.testMode) void this.discordBridge.reload().catch(() => undefined); + if (!this.testMode) void this.discordBridge.ensureLoaded().catch(() => undefined); registerDoctorRoutes(this.app); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -2754,8 +2754,7 @@ export class WebServer extends EventEmitter { id?: string; name?: string; createdAt?: number; - resumeSessionId?: string; - session?: { id?: string; name?: string; createdAt?: number; resumeSessionId?: string }; + session?: { id?: string; name?: string; createdAt?: number }; }; const s = d.session ?? d; if (!s.id) return; @@ -2766,7 +2765,7 @@ export class WebServer extends EventEmitter { this.discordBridge.sessionIdle(id, this.isHintableSession(id)) : event === SseEvent.SessionCreated ? this.discordBridge - .sessionCreated(id, s.name, s.createdAt, !!s.resumeSessionId) + .sessionCreated(id, s.name, s.createdAt) .then(() => this.discordBridge.sessionRenamed(id, s.name)) : this.discordBridge.sessionRenamed(id, s.name); void run.catch(() => undefined); diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts index b6b7f7d7f..fa20caee8 100644 --- a/test/cli-notify.test.ts +++ b/test/cli-notify.test.ts @@ -57,6 +57,7 @@ describe('runNotify', () => { 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'); }); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 25eb08792..c9a577c9c 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -477,6 +477,39 @@ describe('DiscordBridge', () => { }); }); + 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 }); + 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> }; @@ -722,14 +755,6 @@ describe('DiscordBridge files and startup hint', () => { b.stop(); }); - it('does not hint a resumed tab, even though it was created just now', async () => { - const b = await make(); - await b.sessionCreated('s6', 'resumed', t - 1000, true); - await b.sessionIdle('s6', true); - expect(sent).toHaveLength(0); - 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' })); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 74220a84e..96d1e9c91 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -92,7 +92,7 @@ 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, false); + expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123); s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' }); expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed'); }); @@ -111,14 +111,6 @@ describe('broadcast -> Discord tab lifecycle', () => { expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone'); }); - it('marks a resumed tab as resumed, so it is never sent the typed hint', () => { - const s = server(); - s.broadcast(SseEvent.SessionCreated, { session: { id: 's9', name: 'w9', createdAt: 5, resumeSessionId: 'abc' } }); - expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s9', 'w9', 5, true); - s.broadcast(SseEvent.SessionCreated, { session: { id: 's8', name: 'w8', createdAt: 6 } }); - expect(s.discordBridge.sessionCreated).toHaveBeenLastCalledWith('s8', 'w8', 6, false); - }); - it('ignores an event with no session id', () => { const s = server(); s.broadcast(SseEvent.SessionUpdated, {}); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index c4c33ff7e..052ed50a7 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -7,7 +7,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerDiscordRoutes } from '../../src/web/routes/discord-routes.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'; @@ -37,6 +37,7 @@ async function harness(authUser?: { username: string; role: 'admin' | 'user' }) } beforeEach(() => { + _resetNotifyBuckets(); dir = mkdtempSync(join(tmpdir(), 'discord-routes-')); fetchImpl = vi.fn<DiscordFetch>( async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 }) @@ -174,6 +175,23 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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('rejects an empty or oversized message', async () => { const { app } = await harness(); const url = `/api/sessions/test-session-1/discord-notify`; @@ -229,6 +247,15 @@ describe('discord-notify with a file', () => { 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('refuses a symlink that points outside the folder', async () => { const { app, bridge } = await ready(); mkdirSync(join(work, 'sub')); From 1ab54b9e9b42aab800d1120e2e9be58b1ade5372 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:58:17 +0800 Subject: [PATCH 12/24] fix(discord): report a refused post as a failure, bound the file probe, drop threads of a previous channel - codeman notify exits 1 when the route answers 200 with data.ok false - notify request times out at 30 s instead of hanging - fileProblem probes the file with the bounded probe before realpath - the thread map records its parent channel; a new channel starts afresh - threadFor returns its failure instead of sharing this.last --- docs/api-reference.md | 2 +- src/cli-notify.ts | 24 ++++++++--- src/discord-bridge.ts | 65 ++++++++++++++++++++---------- src/web/routes/discord-routes.ts | 4 ++ test/cli-notify.test.ts | 15 ++++++- test/discord-bridge.test.ts | 44 ++++++++++++++++++++ test/routes/discord-routes.test.ts | 17 ++++++++ 7 files changed, 142 insertions(+), 29 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 52f2929e2..77d92d94f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1495,7 +1495,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | `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?, 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. `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. | +| `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, 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)). diff --git a/src/cli-notify.ts b/src/cli-notify.ts index d14f102f8..44e19b727 100644 --- a/src/cli-notify.ts +++ b/src/cli-notify.ts @@ -14,6 +14,8 @@ 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; @@ -32,7 +34,7 @@ export type JsonPost = ( apiUrl: string, apiPath: string, payload: unknown -) => Promise<{ status: number; error?: string }>; +) => 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) => { @@ -56,21 +58,28 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload) => { path: `${url.pathname}${url.search}`, rejectUnauthorized: false, headers, + timeout: REQUEST_TIMEOUT_MS, }, (res) => { let text = ''; res.on('data', (c) => (text += c)); res.on('end', () => { let error: string | undefined; + let delivery: { ok: boolean; error?: string } | undefined; try { - error = (JSON.parse(text) as { error?: string }).error; + 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 }); + 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(); @@ -91,12 +100,17 @@ export async function runNotify( const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message }; if (opts.urgent) payload.urgent = true; if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file); - const { status, error } = await post( + const { status, error, delivery } = await post( apiUrl, `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`, payload ); - if (status >= 200 && status < 300) return { code: 0, out: 'Posted to Discord' }; + 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) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index c6e652683..54105e9d9 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -306,20 +306,32 @@ export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); } -export async function readThreadMap(configDir: string): Promise<Record<string, string>> { +/** The thread map plus the parent channel it was built under (empty for an older flat file). */ +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 out: Record<string, string> = {}; - for (const [k, v] of Object.entries(raw)) if (typeof v === 'string' && SNOWFLAKE.test(v)) out[k] = v; - return out; + const nested = typeof raw.threads === 'object' && raw.threads !== null; + const entries = Object.entries(nested ? (raw.threads as Record<string, unknown>) : raw); + const threads: Record<string, string> = {}; + for (const [k, v] of entries) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v; + const parent = nested && typeof raw.parentChannelId === 'string' ? raw.parentChannelId : ''; + return { parent, threads }; } catch { - return {}; + return { parent: '', threads: {} }; } } -async function writeThreadMap(configDir: string, map: Record<string, string>): Promise<void> { +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), map, 0o600); + await writePrivate(join(configDir, THREADS_FILE), { parentChannelId, threads }, 0o600); } // --------------------------------------------------------------------------- @@ -657,7 +669,9 @@ export class DiscordBridge { 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 | null>>(); + private pending = new Map<string, Promise<string | DiscordResult>>(); + /** The parent channel the thread map was built under. */ + private threadsParent = ''; /** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */ private persistChain: Promise<void> = Promise.resolve(); private mapLoad?: Promise<void>; @@ -693,11 +707,23 @@ export class DiscordBridge { 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 ??= readThreadMap(this.deps.configDir).then((saved) => { - this.threads = { ...saved, ...this.threads }; + 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) { + if (this.threadsParent !== '') { + this.threads = {}; + this.pending.clear(); + } + this.threadsParent = parent; + await this.persistThreads(); + } this.gateway?.stop(); this.gateway = null; activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; @@ -742,13 +768,13 @@ export class DiscordBridge { private persistThreads(): Promise<void> { this.persistChain = this.persistChain - .then(() => writeThreadMap(this.deps.configDir, { ...this.threads })) + .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 | null> { + 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); @@ -756,16 +782,12 @@ export class DiscordBridge { const p = (async () => { try { const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); - if (!t?.id) { - this.last = this.fail(new Error('Discord did not return a thread')); - return null; - } + if (!t?.id) return this.fail(new Error('Discord did not return a thread')); this.threads[sessionId] = t.id; await this.persistThreads(); return t.id; } catch (err) { - this.last = this.fail(err); - return null; + return this.fail(err); } finally { this.pending.delete(sessionId); } @@ -799,7 +821,7 @@ export class DiscordBridge { let target = this.cfg.parentChannelId; if (msg.sessionId) { const thread = await this.threadFor(msg.sessionId, msg.sessionName); - if (!thread) return this.last; + 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)); @@ -810,7 +832,7 @@ export class DiscordBridge { if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) { delete this.threads[msg.sessionId]; const fresh = await this.threadFor(msg.sessionId, msg.sessionName); - if (!fresh) return this.last; + if (typeof fresh !== 'string') return (this.last = fresh); await send(fresh); } else { throw err; @@ -852,7 +874,8 @@ export class DiscordBridge { // 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; - await this.threadFor(sessionId, name); + const made = await this.threadFor(sessionId, name); + if (typeof made !== 'string') this.last = made; } /** diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 42b63a7a3..771e68302 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -96,6 +96,10 @@ async function fileProblem( 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); diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts index fa20caee8..cb7407fea 100644 --- a/test/cli-notify.test.ts +++ b/test/cli-notify.test.ts @@ -10,8 +10,8 @@ import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js'; const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' }; -function fake(status: number, error?: string) { - const post = vi.fn<JsonPost>(async () => ({ status, error })); +function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) { + const post = vi.fn<JsonPost>(async () => ({ status, error, delivery })); return post; } @@ -47,6 +47,17 @@ describe('runNotify', () => { 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('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); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index c9a577c9c..43192995b 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -477,6 +477,50 @@ describe('DiscordBridge', () => { }); }); + 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(); + }); + describe('sendTest', () => { it('posts with the config it is given and leaves the live config alone', async () => { const auth: string[] = []; diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 052ed50a7..282104acf 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -175,6 +175,16 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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 }); @@ -256,6 +266,13 @@ describe('discord-notify with a file', () => { 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')); From dc22519adb24d68eb08a14cecbb78760169f0c2a Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:42:17 +0800 Subject: [PATCH 13/24] fix(discord): discard a thread created for a replaced parent channel, test the notify timeout, document the bridge invariants - a thread creation that finishes after the parent channel changed is dropped, and a finished creation only clears its own pending entry - postJson takes an optional timeout, pinned by a test against a server that never answers - drop the flat discord-bridge.json fallback - add a Discord bridge section to architecture-invariants --- docs/architecture-invariants.md | 71 +++++++++++++++++++-------------- src/cli-notify.ts | 7 ++-- src/discord-bridge.ts | 29 +++++++------- test/cli-notify.test.ts | 13 ++++++ test/discord-bridge.test.ts | 20 ++++++++++ 5 files changed, 93 insertions(+), 47 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebba79736..07d615340 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1,6 +1,6 @@ # Architecture invariants -Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. +Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. `CLAUDE.md` keeps the short form of each rule plus a pointer to the section here; for the frontend (`src/web/public/`) that short form lives in `src/web/CLAUDE.md`, which the root file points to. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint. @@ -181,7 +181,7 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio ### Session lineage lines (tab → tab it spawned) -**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. +**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user _human_ owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. **Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart. @@ -207,7 +207,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. -**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. +**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+\_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. **Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in. @@ -324,7 +324,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. -⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. +⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks _exit_ and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). @@ -421,9 +421,9 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes. -⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top. +⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay _behind_ the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top. -**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `<a:clrScheme>` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `<numFmt>` (cellXfs' and dxfs' alike; the lookahead skips `<numFmts>`) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]*]/g, '')` rescans to the end of the code for every `[` with no later `]` (a 60,000-character code of `[` cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the `Unsupported number format: <code>` notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker: `formatCellValue` caps every display string at `LIMITS.maxCellTextChars` (1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one `nowrap` line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's `text` is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text: `richTextPrefix` also visits at most `LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (`<r/>`, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText` array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts `formatCellValue` returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves: `createXmlCounter` counts every start tag (`<` followed by a letter or `_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/<alnum>.<ext 3-4>$` (ExcelJS's own patterns are unanchored, so `xl/media/xl/drawings/a.xml` is still parsed as a drawing and is counted), into `counts.elements`, and refuses above `LIMITS.maxElements` (2,000,000) as `element-limit`. ExcelJS builds one object per element in every part it parses: each `<r>` run and `<si>` in `xl/sharedStrings.xml`, each `<font>`, `<fill>` and `<border>` in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty `<r/>` runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M `<font/>` in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets, `xl/styles.xml` and `xl/workbook.xml`) carry only a trailing `<` between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more `Unsupported number format` warnings in a tile into one `N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice` has a `max-height` and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by `SPREADSHEET_ASSET_VERSION` in `spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. +**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 _ 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `<a:clrScheme>` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `<numFmt>` (cellXfs' and dxfs' alike; the lookahead skips `<numFmts>`) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]_]/g, '')`rescans to the end of the code for every`[` with no later `]`(a 60,000-character code of`[`cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the`Unsupported number format: <code>`notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker:`formatCellValue`caps every display string at`LIMITS.maxCellTextChars`(1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one`nowrap`line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's`text`is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text:`richTextPrefix`also visits at most`LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (`<r/>`, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText`array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts`formatCellValue`returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves:`createXmlCounter` counts every start tag (`<`followed by a letter or`\_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/<alnum>.<ext 3-4>$`(ExcelJS's own patterns are unanchored, so`xl/media/xl/drawings/a.xml`is still parsed as a drawing and is counted), into`counts.elements`, and refuses above `LIMITS.maxElements`(2,000,000) as`element-limit`. ExcelJS builds one object per element in every part it parses: each `<r>`run and`<si>`in`xl/sharedStrings.xml`, each `<font>`, `<fill>`and`<border>`in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty`<r/>`runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M`<font/>`in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets,`xl/styles.xml`and`xl/workbook.xml`) carry only a trailing `<`between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more`Unsupported number format`warnings in a tile into one`N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice`has a`max-height`and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by`SPREADSHEET_ASSET_VERSION`in`spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. ⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, `<tablePart>`, `<drawing>` or `<hyperlink>` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it. @@ -447,7 +447,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in - **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface. - **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content. - **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick. -- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. +- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is _inside_ the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. - **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff). - **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all. @@ -588,7 +588,7 @@ Further detail: the refused egress targets are `169.254.0.0/16`, `fe80::/10`, `f ⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction. -⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. +⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as _not_ agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. ⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away. @@ -598,7 +598,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 ### Docker Compose deployment -**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases). +**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid _inside_ Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. @@ -782,7 +782,7 @@ Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/ **Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key: -1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top). +1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler _before_ its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top). 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside. @@ -874,9 +874,9 @@ Further detail: in the Settings section order, the version and the updater lead ⚠️ **A first-hand conversation id outranks every correlation, and the correlation must never run when one exists.** `UserPromptSubmit` and `Stop` hook payloads carry `session_id` — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own `$CODEMAN_SESSION_ID`. That binding is a fact: it never consults `workingDir`, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare `claude` in the user's terminal. `Session.claudeSessionIdIsFirstHand` gates `resolveActiveClaudeSessionIdFromHistory()` at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ **This is also the only fix for a pane driven by attaching to its tmux session directly.** `lastSubmitAt` was bumped only by `Session.write()`/`writeViaMux()`, i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at `if (!submitAt)` for the pane's entire life. The hook stamps it too (`markPromptSubmitted()`), so it finally means "a prompt was submitted". ⚠️ **Only a first-hand adoption may extend `Session.claudeSessionChain`** — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because `/clear` is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have `/clear`ed before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ **The hook's stdout must stay discarded, using curl's own `-o /dev/null`** (`curlCmdSilent`): Claude Code injects a `UserPromptSubmit` hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's `{"success":true,…}` envelope into the user's own prompt on every turn. ⚠️ **A trailing `>/dev/null` does NOT work and looks like it does**: `curlCmd` already ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds the redirection to `true`, which never runs on the success path. An `endsWith('>/dev/null')` assertion passes on exactly that broken form, so the test asserts the `-o` flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to `curlCmd`. Tests: `test/hooks-config.test.ts`, `test/routes/hook-event-routes.test.ts`, `test/routes/session-routes-claude-last-response.test.ts`. -⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. +⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its _launch_ conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a _stale_ anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. -⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. +⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per _row_ was the original bug (#169) — but the fix overshot to one card per _human turn_, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. **File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. ### Session list layout (header strip vs. left sidebar) @@ -963,7 +963,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t **Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch. -**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic. +**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to _nothing_ (invalid at computed-value time), so the aliases are load-bearing, not cosmetic. ### Custom branding and UI language @@ -1109,7 +1109,7 @@ The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTime ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. -Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. +Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^\_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all. @@ -1141,7 +1141,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`. -⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. +⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts`by hand and diff the FAIL list against master. (Not`npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. ### SSE staleness watchdog @@ -1183,19 +1183,19 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Layer-by-layer detail -| Layer | Details | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) | -| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` | -| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) | -| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | -| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | -| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | -| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | +| Layer | Details | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) | +| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` | +| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) | +| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | +| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | +| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | +| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | | **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | -| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | -| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | -| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | +| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | +| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | ## Performance and limits @@ -1229,7 +1229,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina ⚠️ **Look in `prebuilds/<platform>-<arch>/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/<platform>-<arch>`, and `unixTerminal.js` derives `helperPath` from **whichever directory the native module loaded out of**. The 0.15 fix only chmodded `build/Release/spawn-helper`, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off `require.resolve('node-pty')` (= `<pkg>/lib/index.js`), producing `<pkg>/lib/build/Release/spawn-helper`, so it was a no-op on every platform. -The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only *then* shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install). +The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only _then_ shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install). `src/utils/node-pty-repair.ts` is the runtime safety net for installs that are already broken: all four `pty.spawn()` calls in `session.ts` go through `spawnPtyWithHelperRepair()`, which chmods and retries **once** on a `posix_spawnp`/`spawn-helper` error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the `npm run fix:node-pty` hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms). @@ -1323,8 +1323,7 @@ starting with a `composerCommandPrefixes` character or whose last word starts wi frame with a `composerPopupLine` row in the block just above the composer is refused at stage and submit: codex draws its popups there and keeps its footer, so the footer alone read `› /stat` with the command list open as idle, and Enter ran `/status`. -⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` -50) for the first frame with the same generation, `revision === before + 1` and +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` 50) for the first frame with the same generation, `revision === before + 1` and `composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys and the first capture is often from before them; another writer or a lifecycle change ends the wait at once, and every refusal after typing says the message may be in the @@ -1364,3 +1363,15 @@ 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, 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. +- **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/src/cli-notify.ts b/src/cli-notify.ts index 44e19b727..af925bcd9 100644 --- a/src/cli-notify.ts +++ b/src/cli-notify.ts @@ -33,11 +33,12 @@ export interface NotifyResult { export type JsonPost = ( apiUrl: string, apiPath: string, - payload: unknown + 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) => { +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); @@ -58,7 +59,7 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload) => { path: `${url.pathname}${url.search}`, rejectUnauthorized: false, headers, - timeout: REQUEST_TIMEOUT_MS, + timeout: timeoutMs, }, (res) => { let text = ''; diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 54105e9d9..b2205e382 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -306,16 +306,14 @@ export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600); } -/** The thread map plus the parent channel it was built under (empty for an older flat file). */ +/** 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 nested = typeof raw.threads === 'object' && raw.threads !== null; - const entries = Object.entries(nested ? (raw.threads as Record<string, unknown>) : raw); const threads: Record<string, string> = {}; - for (const [k, v] of entries) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v; - const parent = nested && typeof raw.parentChannelId === 'string' ? raw.parentChannelId : ''; - return { parent, threads }; + 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: {} }; } @@ -717,10 +715,8 @@ export class DiscordBridge { // or every tab would keep posting into the old one. const parent = this.cfg.parentChannelId; if (parent && parent !== this.threadsParent) { - if (this.threadsParent !== '') { - this.threads = {}; - this.pending.clear(); - } + this.threads = {}; + this.pending.clear(); this.threadsParent = parent; await this.persistThreads(); } @@ -779,19 +775,24 @@ export class DiscordBridge { if (existing) return Promise.resolve(existing); const inflight = this.pending.get(sessionId); if (inflight) return inflight; - const p = (async () => { + const parent = this.cfg.parentChannelId; + const mine: { p?: Promise<string | DiscordResult> } = {}; + const p = (mine.p = (async () => { try { - const t = await this.api.createThread(this.cfg.parentChannelId, threadNameFor(name, sessionId)); + const t = await this.api.createThread(parent, threadNameFor(name, sessionId)); if (!t?.id) return this.fail(new Error('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) return this.fail(new Error('The parent channel changed')); this.threads[sessionId] = t.id; await this.persistThreads(); return t.id; } catch (err) { return this.fail(err); } finally { - this.pending.delete(sessionId); + // 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.pending.set(sessionId, p); return p; } diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts index cb7407fea..a1e6ad3d6 100644 --- a/test/cli-notify.test.ts +++ b/test/cli-notify.test.ts @@ -58,6 +58,19 @@ describe('runNotify', () => { 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('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); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 43192995b..387c668f0 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -521,6 +521,26 @@ describe('DiscordBridge', () => { 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) => { + if (String(url).endsWith(`/channels/${PARENT}/threads`)) { + await gate; + return jsonRes({ id: THREAD }); + } + 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 }); + expect(await readThreadMap(dir)).toEqual({}); + b.stop(); + }); + describe('sendTest', () => { it('posts with the config it is given and leaves the live config alone', async () => { const auth: string[] = []; From 212989b3499c1e17680e2b6c1d0a67773e1c100f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:58:33 +0800 Subject: [PATCH 14/24] fix(discord): digit replies without Enter, start the gateway after restore, hide other users' session ids, fix docs - a lone digit reply is typed without Enter while the tab has a pending permission or question - the gateway starts after restoreMuxSessions (hold and release), so a reply during restore is not told the tab is gone - discord-notify resolves an id prefix only among sessions the caller may see - restore docs/architecture-invariants.md from base and add only the Discord section - docs: self-ignoring folder, no logging, Notifications group, Send test, local-tab-only Claude hint, typedHint wording --- docs/architecture-invariants.md | 59 +++++++++++++++--------------- docs/discord-bridge.md | 12 +++--- docs/security-architecture.md | 2 +- docs/wiki/Discord-Bridge.md | 6 +-- src/discord-bridge.ts | 22 ++++++++++- src/web/public/index.html | 2 +- src/web/routes/discord-routes.ts | 12 +++--- src/web/server.ts | 8 +++- test/discord-bridge.test.ts | 32 +++++++++++++++- test/routes/discord-routes.test.ts | 14 +++++++ 10 files changed, 120 insertions(+), 49 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 07d615340..7fc938d93 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1,6 +1,6 @@ # Architecture invariants -Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. +Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer. `CLAUDE.md` keeps the short form of each rule plus a pointer to the section here; for the frontend (`src/web/public/`) that short form lives in `src/web/CLAUDE.md`, which the root file points to. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint. @@ -181,7 +181,7 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio ### Session lineage lines (tab → tab it spawned) -**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user _human_ owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. +**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it. **Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart. @@ -207,7 +207,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str **The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. -**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+\_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. +**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. **Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in. @@ -324,7 +324,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. -⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks _exit_ and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. +⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). @@ -421,9 +421,9 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes. -⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay _behind_ the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top. +⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top. -**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 _ 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `<a:clrScheme>` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `<numFmt>` (cellXfs' and dxfs' alike; the lookahead skips `<numFmts>`) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]_]/g, '')`rescans to the end of the code for every`[` with no later `]`(a 60,000-character code of`[`cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the`Unsupported number format: <code>`notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker:`formatCellValue`caps every display string at`LIMITS.maxCellTextChars`(1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one`nowrap`line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's`text`is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text:`richTextPrefix`also visits at most`LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (`<r/>`, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText`array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts`formatCellValue`returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves:`createXmlCounter` counts every start tag (`<`followed by a letter or`\_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/<alnum>.<ext 3-4>$`(ExcelJS's own patterns are unanchored, so`xl/media/xl/drawings/a.xml`is still parsed as a drawing and is counted), into`counts.elements`, and refuses above `LIMITS.maxElements`(2,000,000) as`element-limit`. ExcelJS builds one object per element in every part it parses: each `<r>`run and`<si>`in`xl/sharedStrings.xml`, each `<font>`, `<fill>`and`<border>`in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty`<r/>`runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M`<font/>`in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets,`xl/styles.xml`and`xl/workbook.xml`) carry only a trailing `<`between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more`Unsupported number format`warnings in a tile into one`N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice`has a`max-height`and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by`SPREADSHEET_ASSET_VERSION`in`spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. +**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `<a:clrScheme>` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `<numFmt>` (cellXfs' and dxfs' alike; the lookahead skips `<numFmts>`) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]*]/g, '')` rescans to the end of the code for every `[` with no later `]` (a 60,000-character code of `[` cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the `Unsupported number format: <code>` notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker: `formatCellValue` caps every display string at `LIMITS.maxCellTextChars` (1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one `nowrap` line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's `text` is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text: `richTextPrefix` also visits at most `LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (`<r/>`, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText` array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts `formatCellValue` returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves: `createXmlCounter` counts every start tag (`<` followed by a letter or `_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/<alnum>.<ext 3-4>$` (ExcelJS's own patterns are unanchored, so `xl/media/xl/drawings/a.xml` is still parsed as a drawing and is counted), into `counts.elements`, and refuses above `LIMITS.maxElements` (2,000,000) as `element-limit`. ExcelJS builds one object per element in every part it parses: each `<r>` run and `<si>` in `xl/sharedStrings.xml`, each `<font>`, `<fill>` and `<border>` in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty `<r/>` runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M `<font/>` in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets, `xl/styles.xml` and `xl/workbook.xml`) carry only a trailing `<` between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more `Unsupported number format` warnings in a tile into one `N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice` has a `max-height` and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by `SPREADSHEET_ASSET_VERSION` in `spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. ⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, `<tablePart>`, `<drawing>` or `<hyperlink>` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it. @@ -447,7 +447,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in - **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface. - **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content. - **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick. -- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is _inside_ the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. +- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. - **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff). - **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all. @@ -588,7 +588,7 @@ Further detail: the refused egress targets are `169.254.0.0/16`, `fe80::/10`, `f ⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction. -⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as _not_ agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. +⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. ⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away. @@ -598,7 +598,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 ### Docker Compose deployment -**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid _inside_ Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases). +**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. @@ -782,7 +782,7 @@ Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/ **Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key: -1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler _before_ its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top). +1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top). 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside. @@ -874,9 +874,9 @@ Further detail: in the Settings section order, the version and the updater lead ⚠️ **A first-hand conversation id outranks every correlation, and the correlation must never run when one exists.** `UserPromptSubmit` and `Stop` hook payloads carry `session_id` — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own `$CODEMAN_SESSION_ID`. That binding is a fact: it never consults `workingDir`, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare `claude` in the user's terminal. `Session.claudeSessionIdIsFirstHand` gates `resolveActiveClaudeSessionIdFromHistory()` at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ **This is also the only fix for a pane driven by attaching to its tmux session directly.** `lastSubmitAt` was bumped only by `Session.write()`/`writeViaMux()`, i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at `if (!submitAt)` for the pane's entire life. The hook stamps it too (`markPromptSubmitted()`), so it finally means "a prompt was submitted". ⚠️ **Only a first-hand adoption may extend `Session.claudeSessionChain`** — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because `/clear` is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have `/clear`ed before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ **The hook's stdout must stay discarded, using curl's own `-o /dev/null`** (`curlCmdSilent`): Claude Code injects a `UserPromptSubmit` hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's `{"success":true,…}` envelope into the user's own prompt on every turn. ⚠️ **A trailing `>/dev/null` does NOT work and looks like it does**: `curlCmd` already ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds the redirection to `true`, which never runs on the success path. An `endsWith('>/dev/null')` assertion passes on exactly that broken form, so the test asserts the `-o` flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to `curlCmd`. Tests: `test/hooks-config.test.ts`, `test/routes/hook-event-routes.test.ts`, `test/routes/session-routes-claude-last-response.test.ts`. -⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its _launch_ conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a _stale_ anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. +⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. -⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per _row_ was the original bug (#169) — but the fix overshot to one card per _human turn_, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. +⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. **File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. ### Session list layout (header strip vs. left sidebar) @@ -963,7 +963,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t **Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch. -**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to _nothing_ (invalid at computed-value time), so the aliases are load-bearing, not cosmetic. +**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic. ### Custom branding and UI language @@ -1109,7 +1109,7 @@ The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTime ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. -Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^\_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. +Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all. @@ -1141,7 +1141,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`. -⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts`by hand and diff the FAIL list against master. (Not`npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. +⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. ### SSE staleness watchdog @@ -1183,19 +1183,19 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Layer-by-layer detail -| Layer | Details | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) | -| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` | -| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) | -| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | -| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | -| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | -| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | +| Layer | Details | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) | +| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` | +| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) | +| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) | +| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | +| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | +| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter | | **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | -| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | -| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | -| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | +| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) | +| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | ## Performance and limits @@ -1229,7 +1229,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina ⚠️ **Look in `prebuilds/<platform>-<arch>/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/<platform>-<arch>`, and `unixTerminal.js` derives `helperPath` from **whichever directory the native module loaded out of**. The 0.15 fix only chmodded `build/Release/spawn-helper`, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off `require.resolve('node-pty')` (= `<pkg>/lib/index.js`), producing `<pkg>/lib/build/Release/spawn-helper`, so it was a no-op on every platform. -The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only _then_ shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install). +The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only *then* shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install). `src/utils/node-pty-repair.ts` is the runtime safety net for installs that are already broken: all four `pty.spawn()` calls in `session.ts` go through `spawnPtyWithHelperRepair()`, which chmods and retries **once** on a `posix_spawnp`/`spawn-helper` error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the `npm run fix:node-pty` hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms). @@ -1323,7 +1323,8 @@ starting with a `composerCommandPrefixes` character or whose last word starts wi frame with a `composerPopupLine` row in the block just above the composer is refused at stage and submit: codex draws its popups there and keeps its footer, so the footer alone read `› /stat` with the command list open as idle, and Enter ran `/status`. -⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` 50) for the first frame with the same generation, `revision === before + 1` and +⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` +50) for the first frame with the same generation, `revision === before + 1` and `composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys and the first capture is often from before them; another writer or a lifecycle change ends the wait at once, and every refusal after typing says the message may be in the diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 5e29c3cc4..71ebb0890 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -76,9 +76,9 @@ Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, ### 8. Configure Codeman -1. Open **Settings**, **Discord**. +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 **Test**. A test message should appear in the channel. +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. @@ -101,14 +101,14 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push 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. +- **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 already on the PATH of every session 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. Add `.codeman-discord/` to your `.gitignore`. Remote (ssh) tabs have no local folder, so no file is saved for them. +- **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. @@ -117,11 +117,11 @@ The text explains `codeman notify "message" --urgent`. `codeman` is already on t - 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 logs once and backs off. +- 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 **Test**. +- **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 delete `~/.codeman/discord.json`. - If the token is ever pasted somewhere it should not be, reset it straight away. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index da6306dbd..e39919398 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **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:** each alert sends the rendered screen (whatever the agent printed) to Discord, and with **Which events** set to Everything that is every turn end; 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 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. +- **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:** each alert sends the rendered screen (whatever the agent printed) to Discord, and with **Which events** set to Everything that is every turn end; 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/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 79ff70012..095c2dd28 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -54,7 +54,7 @@ permission prompt, a question, an error, or a post an agent marked urgent. 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. +- **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 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. @@ -65,7 +65,7 @@ Discord for them; only the agent-initiated `codeman notify` habit is missing. - **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. Add `.codeman-discord/` to your `.gitignore`. + `[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. @@ -77,7 +77,7 @@ Discord for them; only the agent-initiated `codeman notify` habit is missing. Everything else is dropped. - A reply is cut to one printable line (1000 characters) and limited to one per second per tab. - Posts can ping only the allowlisted users, so agent output cannot ping the whole server. -- A Discord outage or a bad token never affects a session; it logs once and backs off. +- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings. ## Troubleshooting diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index b2205e382..52a725ec4 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -648,6 +648,8 @@ export interface DiscordBridgeDeps { /** Types `text` (already ending in Enter) into the session. 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 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; @@ -670,6 +672,8 @@ export class DiscordBridge { private pending = new Map<string, Promise<string | DiscordResult>>(); /** 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(); private mapLoad?: Promise<void>; @@ -701,6 +705,18 @@ export class DiscordBridge { } /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ + /** 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(); + } + 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 @@ -723,7 +739,7 @@ export class DiscordBridge { this.gateway?.stop(); this.gateway = null; activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; - if (this.cfg.enabled && this.cfg.token) { + if (this.cfg.enabled && this.cfg.token && !this.held) { this.gateway = new DiscordGateway( () => this.cfg.token, (m) => void this.onGatewayMessage(m).catch(() => undefined), @@ -990,7 +1006,9 @@ export class DiscordBridge { return; } if (saved.refused) await this.say(m.channel_id, saved.refused); - const ok = await this.deps.sendInput(sessionId, text, true).catch(() => false); + // 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, { diff --git a/src/web/public/index.html b/src/web/public/index.html index fee13f87a..81c14e39a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3289,7 +3289,7 @@ <h2>Notifications</h2> <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.</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> diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 771e68302..1eb7a90b9 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -15,7 +15,7 @@ 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 { findSessionOrFail, isAdmin, parseBody } from '../route-helpers.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'; @@ -51,10 +51,12 @@ export type DiscordStatus = Omit<DiscordConfig, 'token'> & { * A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at * least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id. */ -function resolveSessionId(ctx: SessionPort, id: string): string { +function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string { if (ctx.sessions.has(id) || id.length < 8) return id; - const hits = [...ctx.sessions.keys()].filter((k) => k.startsWith(id)); - return hits.length === 1 ? hits[0] : id; + // 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 @@ -180,7 +182,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe 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, rawId); + 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)) { diff --git a/src/web/server.ts b/src/web/server.ts index 8b913e3d9..527f1d02b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -520,6 +520,7 @@ export class WebServer extends EventEmitter { if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true; return session.write(text, { fromUser }); }, + 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); @@ -1356,7 +1357,11 @@ export class WebServer extends EventEmitter { ctx, hostTitle: () => this.windowTitle, }); - if (!this.testMode) void this.discordBridge.ensureLoaded().catch(() => undefined); + 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); @@ -3253,6 +3258,7 @@ 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().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 diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 387c668f0..376f244c9 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -541,6 +541,16 @@ describe('DiscordBridge', () => { 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(); + }); + describe('sendTest', () => { it('posts with the config it is given and leaves the live config alone', async () => { const auth: string[] = []; @@ -721,7 +731,11 @@ describe('DiscordBridge files and startup hint', () => { let posted: Array<{ path: string; body?: Record<string, unknown> }>; let downloads: string[]; - function make(cfg: Partial<DiscordConfig> = {}, dirFor: (id: string) => string | undefined = () => work) { + function make( + cfg: Partial<DiscordConfig> = {}, + dirFor: (id: string) => string | undefined = () => work, + awaitingChoice?: (id: string) => boolean + ) { const b = new DiscordBridge({ configDir: dir, fetchImpl: async (url, init) => { @@ -736,6 +750,7 @@ describe('DiscordBridge files and startup hint', () => { }, sendInput: async (id, text, fromUser) => (sent.push([id, text, fromUser]), true), sessionDir: dirFor, + awaitingChoice, socketFactory: () => new FakeSocket(), now: () => t, sleep: async () => undefined, @@ -819,6 +834,21 @@ describe('DiscordBridge files and startup hint', () => { 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' })); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 282104acf..f64bd4a0f 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -162,6 +162,20 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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 }); From 72a59de96c60501c62ef83ecb547ddb554843e03 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:12:41 +0800 Subject: [PATCH 15/24] docs(discord): write down the lone-digit rule, which installs have codeman, and tidy comments - lone digit rule in CLAUDE.md, invariants, user docs, wiki, API reference and code comments - say which installs have the codeman command - reload() comment back above reload(), merge the sendInput comments, typedHint in the hint comment - drop the stray dot after --urgent in the startup hint - config-backups wiki: name the discord.json token, not the URL - comma in the CLAUDE.md auto-named list --- CLAUDE.md | 4 ++-- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/discord-bridge.md | 4 ++-- docs/wiki/Config-Backups.md | 4 ++-- docs/wiki/Discord-Bridge.md | 4 ++-- src/discord-bridge.ts | 21 ++++++++++++++------- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b47e846a8..06aee28bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited, 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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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`. @@ -191,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 `<file>.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 `<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) +**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. diff --git a/docs/api-reference.md b/docs/api-reference.md index 77d92d94f..0f05d5962 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1497,7 +1497,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | `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, 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)). +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 diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7fc938d93..1faf9a9ce 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1369,7 +1369,7 @@ updates `lastSubmitAt` just like any other Enter. `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, capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`. +- **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. diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 71ebb0890..582d7b735 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -9,7 +9,7 @@ Codeman can post to a private Discord server when a session needs you, give ever - Posts when a session asks for permission, asks a question, goes idle, stops or errors. - 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. +- 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)). @@ -104,7 +104,7 @@ 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 already on the PATH of every session 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. +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 diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index a848e8626..e50d07390 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -189,8 +189,8 @@ 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`, `discord.json` (the -URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, +**Credential 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. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 095c2dd28..e54840130 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -15,11 +15,11 @@ and it works when you are away from your network. - **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop, each with what is on the tab's screen right now, so you can see what is being asked. - **One thread per tab** in a parent channel, so a reply always reaches exactly one session. -- **Replies** from an allowed user are typed into that tab as one line plus Enter. +- **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. Every session has the command; see [below](#telling-the-agents). + 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 diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 52a725ec4..dd39f2a57 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -12,7 +12,8 @@ * - 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 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. * @@ -140,7 +141,10 @@ export function maskToken(token: string): string { 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. */ +/** + * 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, ''); @@ -177,13 +181,13 @@ 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 (see DiscordBridge.sessionIdle). No apostrophes: it is + * 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 . The user reads a Discord thread for ' + + '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.'; @@ -645,8 +649,11 @@ export async function ensureReceivedDir(workingDir: string): Promise<string | nu export interface DiscordBridgeDeps { configDir: string; fetchImpl: DiscordFetch; - /** Types `text` (already ending in Enter) into the session. False when the session is gone. */ - /** `fromUser` is true only for what a person typed (a Discord reply), false for text Codeman types itself. */ + /** + * 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 while the session shows a permission or question dialog, which acts on a bare keypress. */ awaitingChoice?: (sessionId: string) => boolean; @@ -704,7 +711,6 @@ export class DiscordBridge { await this.firstLoad; } - /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */ /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */ hold(): void { this.held = true; @@ -717,6 +723,7 @@ export class DiscordBridge { 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 From b68792f988654d43e46a731e4df6f1967d642f15 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:33:34 +0800 Subject: [PATCH 16/24] fix(discord): say when the agent has exited, keep replies in order, settle a stalled response, prune dead tabs - a reply to a tab whose agent has exited is answered in the thread, with no tick - inbound messages run on one chain per thread, so a reply with a file cannot be overtaken - postJson settles when a response stalls after its headers - the thread map is pruned against the live sessions once after boot --- src/cli-notify.ts | 11 ++++++++++- src/discord-bridge.ts | 31 +++++++++++++++++++++++++++++- src/web/server.ts | 6 +++++- test/cli-notify.test.ts | 15 +++++++++++++++ test/discord-bridge.test.ts | 35 +++++++++++++++++++++++++++++++++- test/discord-push-glue.test.ts | 14 ++++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/cli-notify.ts b/src/cli-notify.ts index af925bcd9..5cdae57a7 100644 --- a/src/cli-notify.ts +++ b/src/cli-notify.ts @@ -43,7 +43,14 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST const url = new URL(apiPath, apiUrl); const body = JSON.stringify(payload); const transport = url.protocol === 'https:' ? https : http; - return new Promise((resolve) => { + 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', @@ -64,6 +71,8 @@ export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST (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; diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index dd39f2a57..85f3a4421 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -655,6 +655,8 @@ export interface DiscordBridgeDeps { * 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. */ @@ -683,6 +685,8 @@ export class DiscordBridge { 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>; /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */ private firstLoad?: Promise<void>; @@ -711,6 +715,17 @@ export class DiscordBridge { 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(); + } + /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */ hold(): void { this.held = true; @@ -765,6 +780,7 @@ export class DiscordBridge { this.lastReply.clear(); this.names.clear(); this.fresh.clear(); + this.inbound.clear(); } private fail(err: unknown): DiscordResult { @@ -991,8 +1007,17 @@ export class DiscordBridge { }; } - /** Inbound reply. Every gate here drops silently: a stranger learns nothing. */ + /** 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; @@ -1005,6 +1030,10 @@ export class DiscordBridge { 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); diff --git a/src/web/server.ts b/src/web/server.ts index 527f1d02b..734c8597e 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -520,6 +520,7 @@ export class WebServer extends EventEmitter { 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) => { @@ -3258,7 +3259,10 @@ 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().catch(() => undefined); + void this.discordBridge + .release() + .then(() => this.discordBridge.prune(this.sessions.keys())) + .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 diff --git a/test/cli-notify.test.ts b/test/cli-notify.test.ts index a1e6ad3d6..5127cf54f 100644 --- a/test/cli-notify.test.ts +++ b/test/cli-notify.test.ts @@ -71,6 +71,21 @@ describe('runNotify', () => { } }); + 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); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 376f244c9..0ec6e0085 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -551,6 +551,17 @@ describe('DiscordBridge', () => { 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[] = []; @@ -734,7 +745,8 @@ describe('DiscordBridge files and startup hint', () => { function make( cfg: Partial<DiscordConfig> = {}, dirFor: (id: string) => string | undefined = () => work, - awaitingChoice?: (id: string) => boolean + awaitingChoice?: (id: string) => boolean, + agentExited?: (id: string) => boolean ) { const b = new DiscordBridge({ configDir: dir, @@ -751,6 +763,7 @@ describe('DiscordBridge files and startup hint', () => { sendInput: async (id, text, fromUser) => (sent.push([id, text, fromUser]), true), sessionDir: dirFor, awaitingChoice, + agentExited, socketFactory: () => new FakeSocket(), now: () => t, sleep: async () => undefined, @@ -834,6 +847,26 @@ describe('DiscordBridge files and startup hint', () => { b.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); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 96d1e9c91..e062e4d9a 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -43,6 +43,20 @@ function server(): Internals { 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(); From a698729fb60030cb50767bc4d94a20bfc1d042bf Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:43:09 +0800 Subject: [PATCH 17/24] fix(discord): keep the thread of a discarded rebuild, close one made for a closed tab, unique tmp names, wider prune keep set --- docs/discord-bridge.md | 2 +- src/discord-bridge.ts | 36 ++++++++++++++++++++++---- src/web/server.ts | 14 +++++++--- test/agent-inbox-server-wiring.test.ts | 2 +- test/discord-bridge.test.ts | 35 +++++++++++++++++++++++++ test/discord-push-glue.test.ts | 15 +++++++++++ 6 files changed, 94 insertions(+), 10 deletions(-) diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 582d7b735..70beb5f80 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -122,7 +122,7 @@ The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH ## 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 delete `~/.codeman/discord.json`. +- **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 diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 85f3a4421..c20012f34 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -22,6 +22,7 @@ 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'; @@ -294,7 +295,8 @@ export async function readDiscordConfig(configDir: string): Promise<DiscordConfi } async function writePrivate(target: string, data: unknown, mode: number): Promise<void> { - const tmp = `${target}.${process.pid}.tmp`; + // 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); @@ -679,6 +681,8 @@ export class DiscordBridge { /** 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 parent channel the thread map was built under. */ private threadsParent = ''; /** True from boot until the restored tabs exist (see hold). */ @@ -822,6 +826,10 @@ export class DiscordBridge { if (!t?.id) return this.fail(new Error('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) return this.fail(new Error('The parent channel changed')); + if (this.closedWhilePending.has(sessionId)) { + await this.closeThread(t.id); + return this.fail(new Error('The tab was closed')); + } this.threads[sessionId] = t.id; await this.persistThreads(); return t.id; @@ -829,7 +837,10 @@ export class DiscordBridge { 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); + if (this.pending.get(sessionId) === mine.p) { + this.pending.delete(sessionId); + this.closedWhilePending.delete(sessionId); + } } })()); this.pending.set(sessionId, p); @@ -940,16 +951,31 @@ export class DiscordBridge { await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined); } - /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */ - async sessionDeleted(sessionId: string): Promise<void> { - // Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server. + /** + * 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); + } + + /** 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); diff --git a/src/web/server.ts b/src/web/server.ts index 734c8597e..a702cc354 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3261,7 +3261,14 @@ export class WebServer extends EventEmitter { await this.finalizeRestoredState(restored); void this.discordBridge .release() - .then(() => this.discordBridge.prune(this.sessions.keys())) + .then(() => + // Same keep set as the agent inbox prune: a reboot-restore rebuild reuses the old id. + this.discordBridge.prune([ + ...this.sessions.keys(), + ...Object.keys(this.store.getSessions()), + ...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId), + ]) + ) .catch(() => undefined); // Instance-scoped reaper: after restore, `docker rm -f` managed containers of @@ -3847,8 +3854,9 @@ export class WebServer extends EventEmitter { } catch (err) { console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); } - // A half-built session never had a thread, but its per-tab bridge state goes with it. - void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined); + // 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 }); diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts index 41963b738..1b8e5a840 100644 --- a/test/agent-inbox-server-wiring.test.ts +++ b/test/agent-inbox-server-wiring.test.ts @@ -50,7 +50,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe 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 () => {} }, + discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} }, broadcast: () => {}, }); return server; diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 0ec6e0085..3dc483b28 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -663,6 +663,41 @@ describe('DiscordBridge', () => { 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(); + await first; + expect(posts).toContainEqual({ path: `/channels/${THREAD}`, method: 'PATCH', body: { archived: true } }); + 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); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index e062e4d9a..ac1e210a5 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -25,6 +25,7 @@ type Internals = { 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 { @@ -35,6 +36,7 @@ function server(): Internals { 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(), }; @@ -125,6 +127,19 @@ describe('broadcast -> Discord tab lifecycle', () => { 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, {}); From d044e4963552ffa5f5c3bfea92deab5263c50edf Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:58:03 +0800 Subject: [PATCH 18/24] fix(discord): show the real reason a thread fails, keep the gateway across a save, refund the rate limit for a refused file --- src/discord-bridge.ts | 28 ++++++++++++++++++++-------- src/web/routes/discord-routes.ts | 7 +++++++ test/discord-bridge.test.ts | 18 ++++++++++++++++-- test/routes/discord-routes.test.ts | 11 +++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index c20012f34..7118a6988 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -286,6 +286,9 @@ function coerce(raw: unknown): DiscordConfig { }; } +/** 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'))); @@ -674,6 +677,8 @@ export class DiscordBridge { 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. */ @@ -762,10 +767,14 @@ export class DiscordBridge { 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. + const want = this.cfg.enabled && this.cfg.token && !this.held ? this.cfg.token : ''; + if (this.gateway && want && want === this.gatewayToken) return; this.gateway?.stop(); this.gateway = null; - activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined; - if (this.cfg.enabled && this.cfg.token && !this.held) { + this.gatewayToken = want; + if (want) { this.gateway = new DiscordGateway( () => this.cfg.token, (m) => void this.onGatewayMessage(m).catch(() => undefined), @@ -779,6 +788,7 @@ export class DiscordBridge { stop(): void { this.gateway?.stop(); this.gateway = null; + this.gatewayToken = ''; activeStartupHint = undefined; this.lastSent.clear(); this.lastReply.clear(); @@ -799,9 +809,11 @@ export class DiscordBridge { ? 'Channel not found (check the IDs)' : status ? `HTTP ${status}` - : (err as { name?: string })?.name === 'TimeoutError' - ? 'Timed out' - : 'Network error'; + : err instanceof BridgeProblem + ? err.message + : (err as { name?: string })?.name === 'TimeoutError' + ? 'Timed out' + : 'Network error'; return { ok: false, status, error, at: this.now() }; } @@ -823,12 +835,12 @@ export class DiscordBridge { const p = (mine.p = (async () => { try { const t = await this.api.createThread(parent, threadNameFor(name, sessionId)); - if (!t?.id) return this.fail(new Error('Discord did not return a thread')); + 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) return this.fail(new Error('The parent channel changed')); + if (parent !== this.cfg.parentChannelId) return this.fail(new BridgeProblem('The parent channel changed')); if (this.closedWhilePending.has(sessionId)) { await this.closeThread(t.id); - return this.fail(new Error('The tab was closed')); + return this.fail(new BridgeProblem('The tab was closed')); } this.threads[sessionId] = t.id; await this.persistThreads(); diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 1eb7a90b9..10669ebd7 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -77,6 +77,12 @@ function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolea 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(); @@ -197,6 +203,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe 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); } diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 3dc483b28..222eb9b60 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -582,7 +582,20 @@ describe('DiscordBridge', () => { 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 }); + expect(r).toMatchObject({ ok: false, error: 'Discord did not return a thread' }); + 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(); }); @@ -692,8 +705,9 @@ describe('DiscordBridge', () => { await vi.waitFor(() => expect(posts.some((p) => p.path.endsWith('/threads'))).toBe(true)); await b.sessionDeleted('s1'); release(); - await first; + 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(); }); diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index f64bd4a0f..cae331231 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -216,6 +216,17 @@ describe('POST /api/sessions/:id/discord-notify', () => { 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`; From 9f18b78857c3cdd0d4fcedc38c49c31990262398 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:27:36 +0800 Subject: [PATCH 19/24] fix(discord): replace a gateway that gave up when the same config is saved again --- src/discord-bridge.ts | 3 ++- test/discord-bridge.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 7118a6988..e43d05251 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -769,8 +769,9 @@ export class DiscordBridge { } 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 && want && want === this.gatewayToken) return; + if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken) return; this.gateway?.stop(); this.gateway = null; this.gatewayToken = want; diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 222eb9b60..9f69fa70a 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -586,6 +586,30 @@ describe('DiscordBridge', () => { 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(); From 97c698b2496f7bd5085ccb9de7cacc2db6b56044 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:42:48 +0800 Subject: [PATCH 20/24] fix(discord): ignore gateway frames after stop, hold the typed hint while a choice is pending, name the Discord group in the settings reference --- docs/wiki/Settings-Reference.md | 5 +++-- src/discord-bridge.ts | 7 ++++++- test/discord-bridge.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) 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/src/discord-bridge.ts b/src/discord-bridge.ts index e43d05251..2ec15282a 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -535,6 +535,8 @@ export class DiscordGateway { } 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); @@ -947,7 +949,10 @@ export class DiscordBridge { * 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.delete(sessionId)) return; + if (!this.fresh.has(sessionId)) 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); if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return; 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. diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 9f69fa70a..be45ea273 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -247,6 +247,15 @@ describe('DiscordGateway', () => { 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(); @@ -920,6 +929,20 @@ describe('DiscordBridge files and startup hint', () => { 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('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: [] })); From d6c7d30bd45ad134b6eb00b97250536250cdbb1e Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:42:33 +0800 Subject: [PATCH 21/24] fix(discord): tidy a thread made in the old channel, keep the two pending sets in step, settle the hint when the settings are off --- src/discord-bridge.ts | 11 +++++++++-- test/discord-bridge.test.ts | 20 +++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 2ec15282a..29095f947 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -766,6 +766,7 @@ export class DiscordBridge { if (parent && parent !== this.threadsParent) { this.threads = {}; this.pending.clear(); + this.closedWhilePending.clear(); this.threadsParent = parent; await this.persistThreads(); } @@ -840,7 +841,10 @@ export class DiscordBridge { 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) return this.fail(new BridgeProblem('The parent channel changed')); + 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')); @@ -950,10 +954,13 @@ export class DiscordBridge { */ 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); - if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return; 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); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index be45ea273..8b24d71e1 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -533,11 +533,16 @@ describe('DiscordBridge', () => { 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) => { + 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); @@ -546,6 +551,8 @@ describe('DiscordBridge', () => { 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(); }); @@ -943,6 +950,17 @@ describe('DiscordBridge files and startup hint', () => { 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: [] })); From 093f242662c1527ec59f7cfe944e697d62fa791a Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:26:35 +0800 Subject: [PATCH 22/24] feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/discord-bridge.md | 20 +- docs/wiki/Discord-Bridge.md | 22 +- src/discord-bridge.ts | 236 +++++++++++++++--- src/types/discord.ts | 65 +++++ src/types/index.ts | 2 + src/web/ports/session-port.ts | 5 + src/web/public/index.html | 8 + src/web/public/settings-ui.js | 2 + src/web/routes/discord-routes.ts | 12 +- src/web/routes/session-routes.ts | 28 ++- src/web/schemas.ts | 3 +- src/web/server.ts | 44 +++- test/discord-bridge.test.ts | 180 ++++++++++++- test/discord-push-glue.test.ts | 41 +++ .../discord-last-message-per-cli.test.ts | 196 +++++++++++++++ test/routes/discord-routes.test.ts | 8 +- 18 files changed, 779 insertions(+), 97 deletions(-) create mode 100644 src/types/discord.ts create mode 100644 test/routes/discord-last-message-per-cli.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 06aee28bd..9aba4b569 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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` +**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 (idle or stop) 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 never posted twice, 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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 0f05d5962..e1a0d7abf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1493,7 +1493,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | 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?, 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. | +| `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. | diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 70beb5f80..622f2b2e4 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -7,7 +7,8 @@ Codeman can post to a private Discord server when a session needs you, give ever ## What it does -- Posts when a session asks for permission, asks a question, goes idle, stops or errors. +- Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything. +- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept, split over several posts if it is long. This 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. @@ -88,14 +89,15 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push ## Options -| Option | Values | Default | Effect | -| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- | -| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | -| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | -| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | -| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | -| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | -| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | +| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | +| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | ## Telling the CLIs diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index e54840130..976fc0c23 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -38,14 +38,18 @@ only), never in the shared settings, and never shown again once saved. ## Options -| Option | Choices | Default | -| ---------------------------------- | ---------------------------------------- | --------------- | -| Create threads | On the first alert, or for every new tab | First alert | -| When a tab is deleted | Keep, archive or delete the thread | Archive | -| @ ping | Never, critical events only, every post | Critical only | -| Which events | Needs attention, or everything | Needs attention | -| Tell Claude how to reach you | On or off | On | -| Also tell other CLIs, as a message | On or off | Off | +| 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 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. 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. @@ -55,7 +59,7 @@ permission prompt, a question, an error, or a post an agent marked urgent. 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 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. +- **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 diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 29095f947..6c46b0d99 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -26,7 +26,16 @@ 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 WebhookScope, type WebhookUrgency } from './types/push.js'; +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'; @@ -44,33 +53,14 @@ 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}$/; - -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; - 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; -} +/** 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, @@ -80,6 +70,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { allowedUserIds: [], threadCreation: 'lazy', onSessionDelete: 'archive', + staleThreads: 'keep', pingMode: 'critical', scope: 'attention', startupHint: true, @@ -95,6 +86,11 @@ export interface DiscordMessage { 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<string | undefined>; host?: string; /** A file to upload with the post (already read and size-checked by the caller). */ file?: DiscordFile; @@ -105,17 +101,6 @@ export interface DiscordFile { data: Uint8Array; } -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'; - // --------------------------------------------------------------------------- // Pure // --------------------------------------------------------------------------- @@ -201,9 +186,12 @@ export function getDiscordStartupHint(): string | undefined { /** * 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): string { - const lines = screen +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 @@ -211,11 +199,60 @@ export function screenTail(screen: string, maxLines = 14, maxChars = 900): strin .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); } +/** + * 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(/^[._]+/, ''); @@ -279,6 +316,7 @@ function coerce(raw: unknown): DiscordConfig { : [], 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, @@ -286,6 +324,17 @@ function coerce(raw: unknown): DiscordConfig { }; } +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 {} @@ -345,6 +394,13 @@ async function writeThreadMap( // 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 { @@ -414,6 +470,17 @@ export class DiscordApi { 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', @@ -690,6 +757,8 @@ export class DiscordBridge { 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). */ @@ -699,6 +768,7 @@ export class DiscordBridge { /** 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; @@ -737,6 +807,63 @@ export class DiscordBridge { 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; @@ -790,6 +917,8 @@ export class DiscordBridge { } stop(): void { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; this.gateway?.stop(); this.gateway = null; this.gatewayToken = ''; @@ -798,6 +927,7 @@ export class DiscordBridge { this.lastReply.clear(); this.names.clear(); this.fresh.clear(); + this.lastFull.clear(); this.inbound.clear(); } @@ -886,7 +1016,29 @@ export class DiscordBridge { if (this.lastSent.size > 256) { for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); } + let full: string | undefined; + if (msg.fullText) { + try { + full = (await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS))?.trim() || undefined; + } catch { + full = undefined; // no transcript to read: the screen tail below stands in + } + } + let chunks: string[] = []; + if (full && msg.sessionId) { + if (this.lastFull.get(msg.sessionId) === full) + msg = { ...msg, tail: undefined }; // said already + else { + this.lastFull.set(msg.sessionId, full); + chunks = splitForDiscord(full); + msg = { ...msg, tail: undefined }; + } + } 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) { @@ -908,6 +1060,7 @@ export class DiscordBridge { throw err; } } + 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)); @@ -985,6 +1138,7 @@ export class DiscordBridge { 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. */ 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 81c14e39a..cb0ae2e6d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3278,6 +3278,14 @@ <h2>Notifications</h2> <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"> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d1311e476..1c2795455 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1767,6 +1767,7 @@ Object.assign(CodemanApp.prototype, { 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; @@ -1792,6 +1793,7 @@ Object.assign(CodemanApp.prototype, { 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, diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 10669ebd7..f77df9e13 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -27,10 +27,8 @@ import { readDiscordConfig, writeDiscordConfig, type DiscordBridge, - type DiscordConfig, - type DiscordGatewayState, - type DiscordResult, } from '../../discord-bridge.js'; +import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js'; export interface DiscordRouteDeps { bridge: DiscordBridge; @@ -39,14 +37,6 @@ export interface DiscordRouteDeps { hostTitle: () => string; } -export type DiscordStatus = Omit<DiscordConfig, 'token'> & { - hasToken: boolean; - tokenMasked: string; - gateway: DiscordGatewayState; - gatewayError: string | null; - lastResult: DiscordResult | null; -}; - /** * A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at * least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id. 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 5b4b3f096..e97b1169b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -20,7 +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 '../discord-bridge.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'; @@ -2229,6 +2229,7 @@ export const DiscordUpdateSchema = z 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(), diff --git a/src/web/server.ts b/src/web/server.ts index a702cc354..253ac36bc 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -46,6 +46,7 @@ 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'; @@ -509,6 +510,8 @@ export class WebServer extends EventEmitter { /** 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, @@ -1334,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); @@ -3013,6 +3018,9 @@ export class WebServer extends EventEmitter { // 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, @@ -3022,7 +3030,8 @@ export class WebServer extends EventEmitter { 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) : undefined, + tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined, + fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined, host: this.windowTitle, }) .catch(() => undefined); @@ -3113,9 +3122,25 @@ export class WebServer extends EventEmitter { * (`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: { muxName?: string | null; textOutput: string }): string { + private discordTail(session: Session, cutComposer: boolean): string { const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null; - return screenTail(pane ?? session.textOutput ?? ''); + // 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<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; + } + return (await this.lastResponseFor(session)).text; } /** The notification body for an event (shared by Web Push and the webhook channel). */ @@ -3261,14 +3286,17 @@ export class WebServer extends EventEmitter { await this.finalizeRestoredState(restored); void this.discordBridge .release() - .then(() => - // Same keep set as the agent inbox prune: a reboot-restore rebuild reuses the old id. - this.discordBridge.prune([ + .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 diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 8b24d71e1..5d11b59ed 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -16,6 +16,7 @@ import { 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, @@ -23,6 +24,7 @@ import { isDiscordCdnUrl, safeAttachmentName, screenTail, + splitForDiscord, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -36,7 +38,6 @@ import { shouldPing, threadNameFor, writeDiscordConfig, - type DiscordConfig, type DiscordFetch, type DiscordMessage, type GatewaySocket, @@ -557,6 +558,129 @@ describe('DiscordBridge', () => { 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 () => 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('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 () => 'Short answer.' }); + t += 10_000; + posts.length = 0; + await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => '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('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(); @@ -1028,7 +1152,61 @@ describe('DiscordBridge files and startup hint', () => { }); }); +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', diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index ac1e210a5..4ea071147 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -22,6 +22,7 @@ type Internals = { 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>; @@ -88,6 +89,46 @@ describe('sendPushNotifications -> Discord', () => { 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' })); + 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()).toBe('The full answer.\n\n- one\n- two'); + 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()).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' }); 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 index cae331231..288b54c75 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -72,9 +72,13 @@ describe('/api/discord', () => { 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' } }); + 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' }); + 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(); From e5d82eb044d51e9d9a6015ce3bf004a6a7ce0807 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:58:44 +0800 Subject: [PATCH 23/24] feat(discord): post a Markdown table as an aligned code block --- docs/discord-bridge.md | 2 +- src/discord-bridge.ts | 49 ++++++++++++++++++++++++++++- test/discord-bridge.test.ts | 63 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 622f2b2e4..680d4fb27 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -8,7 +8,7 @@ Codeman can post to a private Discord server when a session needs you, give ever ## What it does - Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything. -- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept, split over several posts if it is long. This 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. +- Posts a finished turn 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. This 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. diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 6c46b0d99..be5238766 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -213,6 +213,53 @@ export function screenTail(screen: string, maxLines = 14, maxChars = 900, compos 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() + ); + }; + const isSeparator = (row: string) => row.includes('|') && /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row); + 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 @@ -1030,7 +1077,7 @@ export class DiscordBridge { msg = { ...msg, tail: undefined }; // said already else { this.lastFull.set(msg.sessionId, full); - chunks = splitForDiscord(full); + chunks = splitForDiscord(tablesToCodeBlocks(full)); msg = { ...msg, tail: undefined }; } } diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 5d11b59ed..7112342c1 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -25,6 +25,7 @@ import { safeAttachmentName, screenTail, splitForDiscord, + tablesToCodeBlocks, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -579,6 +580,24 @@ describe('DiscordBridge', () => { 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 () => `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 () => 'Short answer.' }); @@ -1152,6 +1171,50 @@ describe('DiscordBridge files and startup hint', () => { }); }); +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('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']); From 021641911ed95957e09c8ee9f54b8745892c2455 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:28:35 +0800 Subject: [PATCH 24/24] fix(discord): make the table separator test linear, record a posted message only after it posts, say which CLIs raise a turn end --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 1 + docs/discord-bridge.md | 4 +-- docs/security-architecture.md | 2 +- docs/wiki/Discord-Bridge.md | 15 +++++++--- src/discord-bridge.ts | 38 ++++++++++++++---------- src/web/server.ts | 6 ++-- test/discord-bridge.test.ts | 52 ++++++++++++++++++++++++++++++--- test/discord-push-glue.test.ts | 9 ++++-- 9 files changed, 97 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9aba4b569..5c5625260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ 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 (idle or stop) 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 never posted twice, 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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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` +**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 `<tab folder>/.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 "<message>" [--urgent] [--file <path>]` 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`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1faf9a9ce..5a7d73d2e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1375,4 +1375,5 @@ updates `lastSubmitAt` just like any other Enter. - **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 index 680d4fb27..d757a32e8 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -7,8 +7,8 @@ Codeman can post to a private Discord server when a session needs you, give ever ## What it does -- Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything. -- Posts a finished turn 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. This 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. +- 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. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index e39919398..3c68a096a 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -582,7 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom - **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. - **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. -- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned, logged or put in an error. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **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:** each alert sends the rendered screen (whatever the agent printed) to Discord, and with **Which events** set to Everything that is every turn end; 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. +- **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/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 976fc0c23..445b9d6b4 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -12,8 +12,9 @@ and it works when you are away from your network. ## What it does -- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop, - each with what is on the tab's screen right now, so you can see what is being asked. +- **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 @@ -48,8 +49,14 @@ only), never in the shared settings, and never shown again once saved. | 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 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. +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. diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index be5238766..ff62fdd42 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -90,7 +90,7 @@ export interface DiscordMessage { * 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<string | undefined>; + 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; @@ -231,7 +231,11 @@ export function tablesToCodeBlocks(text: string): string { .trim() ); }; - const isSeparator = (row: string) => row.includes('|') && /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row); + // 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; @@ -1063,22 +1067,25 @@ export class DiscordBridge { if (this.lastSent.size > 256) { for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k); } - let full: string | undefined; + 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 { - full = (await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS))?.trim() || undefined; + 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 { - full = undefined; // no transcript to read: the screen tail below stands in - } - } - let chunks: string[] = []; - if (full && msg.sessionId) { - if (this.lastFull.get(msg.sessionId) === full) - msg = { ...msg, tail: undefined }; // said already - else { - this.lastFull.set(msg.sessionId, full); - chunks = splitForDiscord(tablesToCodeBlocks(full)); - msg = { ...msg, tail: undefined }; + // no transcript to read: the screen tail stands in } } const post = buildPost(msg, this.cfg, explicitUrgent); @@ -1107,6 +1114,7 @@ export class DiscordBridge { 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) { diff --git a/src/web/server.ts b/src/web/server.ts index 253ac36bc..810fe27f7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3134,13 +3134,15 @@ export class WebServer extends EventEmitter { * 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<string | undefined> { + 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; } - return (await this.lastResponseFor(session)).text; + 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). */ diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 7112342c1..1b71c354d 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -566,7 +566,7 @@ describe('DiscordBridge', () => { 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 () => LONG }); + 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); @@ -588,7 +588,7 @@ describe('DiscordBridge', () => { '| kv-f16 | 557.0 |', '| n-gram mod | 550.2 |', ].join('\n'); - await b.notify({ ...MSG, fullText: async () => `Results:\n\n${table}` }); + 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)) @@ -600,10 +600,10 @@ describe('DiscordBridge', () => { 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 () => 'Short answer.' }); + 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 () => 'Short answer.' }); + 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.'); @@ -615,6 +615,39 @@ describe('DiscordBridge', () => { 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({ @@ -1208,6 +1241,17 @@ describe('tablesToCodeBlocks', () => { 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'); diff --git a/test/discord-push-glue.test.ts b/test/discord-push-glue.test.ts index 4ea071147..0a8d86d15 100644 --- a/test/discord-push-glue.test.ts +++ b/test/discord-push-glue.test.ts @@ -92,12 +92,15 @@ describe('sendPushNotifications -> Discord', () => { 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' })); + 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()).toBe('The full answer.\n\n- one\n- two'); + 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]; @@ -114,7 +117,7 @@ describe('sendPushNotifications -> Discord', () => { 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()).toBe('1. First?\n2. Second?\n3. Third?'); + expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?'); } );