diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 3d3df9ae..4fceaf6e 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
- "version": "1.47.0",
+ "version": "1.48.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4dbc0948..a41256ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,39 @@
# aicodeman
+## 1.48.0
+
+### Minor Changes
+
+- 4960bee: ### Thanks
+ - @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
+ - @Randalix for the Edit history panel (#693).
+ - @aakhter for relative links that follow your `cd` (#740).
+ - @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
+ - @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
+ - @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
+
+ **Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "" [--urgent] [--file ]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
+
+ **Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
+
+ **Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
+
+ **Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
+
+ **Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
+
+ **Fixes.**
+ - **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
+ - **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
+ - **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
+ - **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
+ - **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
+ - **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
+
+ **Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
+
+ **Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
+
## 1.47.0
### Minor Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index 3926a6b0..4f350fdb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
-**Version**: 1.47.0 (must match `package.json`)
+**Version**: 1.48.0 (must match `package.json`)
## Project Overview
@@ -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, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) No other CLI is told. `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)
@@ -183,13 +185,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
-**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
+**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**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` 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 `: ` form, so the case identity and `w` 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`, through the bridge's `sendInput`) 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-` / `dscbot-` 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.
@@ -226,13 +228,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
-**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
+**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
-**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
+**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
-**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
+**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
@@ -240,11 +242,13 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itself as the fetch (`GitFetchRun` carries it, and the scheduler publishes the run's trigger for the same reason: its tick can join a click too). The UI's "by click" text depends on it. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, `test/git-fetch-session-options-ui.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate).
+**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
+
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view//...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `
@@ -479,6 +480,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 and 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 +977,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/README.zh-CN.md b/README.zh-CN.md
index add70ccf..e8fd544e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -21,6 +21,7 @@
+
diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json
index dd87b908..6442f4c0 100644
--- a/config/mcp-registry/server.json
+++ b/config/mcp-registry/server.json
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
- "version": "1.47.0",
+ "version": "1.48.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
- "version": "1.47.0",
+ "version": "1.48.0",
"transport": {
"type": "stdio"
},
diff --git a/config/test-suites.ts b/config/test-suites.ts
index ae974542..49812b3a 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -19,6 +19,7 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
+ 'test/wrapped-file-paths.browser.test.ts',
'test/command-palette-agent-arrows.browser.test.ts',
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
@@ -49,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [
'test/case-custom-path.browser.test.ts',
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
+ 'test/edit-history.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
@@ -57,6 +59,7 @@ export const BROWSER_TEST_GLOBS = [
'test/markdown-anchor-links.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
'test/terminal-touch-swipe-axis.browser.test.ts',
+ 'test/phone-press-feedback.browser.test.ts',
];
/**
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 4a0cb6b7..9a269ad3 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -900,6 +900,16 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
keeps the server from storing what it would serve or an agent could run, and
html and svg are the ones deliberately kept out.
+## New folder in the path picker
+
+`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
+folder directly inside `path`, which must be a folder the path picker may browse, and
+answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not
+starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path`
+that is not a directory, `403` for a blocked tree or a `path` outside the browse roots,
+`404` for a `path` that does not exist, `409` when the folder already exists, and `422`
+when the mount under `path` does not answer.
+
## Rendered HTML files
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
@@ -969,6 +979,18 @@ also pure decoration: it confers no permission, and a child is unaffected by its
parent exiting. It appears on session state as `parentSessionId` (absent when
unresolved) and survives a server restart.
+## Pane directory (`paneCwd`)
+
+Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`,
+the directory the session's local tmux pane is in now (`#{pane_current_path}`), which
+the web UI uses as the base for a relative path printed in the terminal. It is
+never restored after a restart (it is written to `state.json` with the rest of the
+session state but not read back), absent for remote,
+Docker, direct-PTY and rediscovered sessions and for a session split into several panes,
+and never a confinement root: file routes stay confined to `workingDir`.
+tmux reports the resolved directory, so for a workspace reached through a symlink a
+directory at or under the resolved workspace is reported in `workingDir`'s spelling.
+
## Session model (`displayModel`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
@@ -1205,24 +1227,47 @@ data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
boolean` reports whether one is stored, so a client can render "unchanged
if left blank" without ever holding the real value.
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
-authStyle?, defaultModelId? }` creates one. `id` must match
+authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id`
+ must match
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
both (a real server hung indefinitely when sent both headers on one
- request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
+ request); `defaultContextLength` is a user-set fallback used when
+ assigning a model that discovery has no context length for (discovery
+ itself never writes it); `baseUrl` must be `http(s)`, carry no embedded
+ credentials, and
is refused if it points at (or resolves to) a link-local or
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
-- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
+- `PUT /api/v1/model-endpoints/:id` updates one, also accepting
+ `defaultContextLength`. An **absent** `apiKey`
keeps the stored one rather than clearing it — the client never receives
the real value to resend deliberately unchanged, so omission is the only
way to say "leave it alone"; there is no way to clear a key back to unset
- this way. `defaultModelId`, when set, must be one of that endpoint's own
+ this way. `defaultContextLength` behaves the opposite way: a PUT without
+ it clears the stored value, so send the field back to keep it.
+ `defaultModelId`, when set, must be one of that endpoint's own
`models` (`400 INVALID_INPUT` otherwise).
- `DELETE /api/v1/model-endpoints/:id` removes one.
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
own `GET /v1/models` and stores the result as `models`, updating
- `lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own
- response already reports loaded) `modelContextLengths` and `modelSizesGB`.
- A `defaultModelId` that no longer appears in the fresh list is dropped
+ `lastDiscoveredAt`, plus (best-effort) `modelContextLengths`,
+ `modelSlotCounts` and `modelSizesGB`: every listing entry is read for a
+ stated context figure (`context_length`, then `context_window`) and a
+ size, BEFORE any loaded check; only the launch-command/`/props`
+ enrichment needs a model llama-swap's own response reports loaded. A
+ loaded model's context figure comes from llama-swap's own launch command
+ (`--fit-ctx` / `-c` / `--ctx-size`), falling back to `/props` (`n_ctx`,
+ `total_slots`) for whatever the command did not answer, and OVERWRITES the
+ listed figure for that model; the model's own listing
+ (`context_length`, then `context_window`) only ever fills the gap when
+ neither probe does, and is recorded as a single-slot figure:
+ llama-swap lists the PER-SLOT window, so whenever the listed figure is
+ the one taken the stored slot count is 1. A server with no `status`
+ field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no
+ launch-command/`/props` enrichment at all, only whatever its listings
+ state. `modelContextLengths` falls back to the
+ endpoint's `defaultContextLength` when a session is assigned a model
+ discovery never learned a window for. A `defaultModelId` that no longer
+ appears in the fresh list is dropped
rather than carried forward invalid. Failures answer `422 OPERATION_FAILED`
with the underlying connection error, or a named egress refusal if the
resolved address turned out to be blocked. The same refresh also runs
@@ -1315,6 +1360,8 @@ The target is judged before anything is written:
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
+`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `/` as always. With `path` the repository is cloned **into that exact folder** (no `` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing.
+
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
@@ -1346,6 +1393,19 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
+
+## Edit history
+
+What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
+
+`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
+
+`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
+
+`POST /api/sessions/:id/edits/older` reads the next older block, within the current conversation, of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list.
+
+The edits are stored per session in `~/.codeman/edit-history/.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
+
## Git worktrees of a case
A local case whose folder is the top of a git repository can keep **worktrees** at `/.claude/worktrees/`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree ` uses (branch `worktree-`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
@@ -1486,6 +1546,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
+## Discord bridge
+
+One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode.
+
+| Method | Path | Body | Notes |
+| ------ | ------ | ------ | ------ |
+| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. |
+| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint? }` (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, not on the attachment blocklist, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. |
+
+Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)).
+
## Diagnostics
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index ebba7973..479d279b 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w-` / `s-`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
-**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 origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply 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.
@@ -409,10 +409,16 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
-**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
+**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
+
+**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc//cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
+Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole.
+
+Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess.
+
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
@@ -429,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
### Filesystem path picker
-**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
+**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
@@ -468,11 +474,13 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
### Clone a repository as a case
-**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
+**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
+- ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it.
+- ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches.
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into ''…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
@@ -628,17 +636,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
-⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
+⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
-⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
+⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model `, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
-**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
+**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
-⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
+⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
@@ -742,6 +750,24 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
+### Edit history panel
+
+**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
+
+⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
+
+⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `/subagents/agent-.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending.
+
+⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write.
+
+⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
+
+⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
+
+⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
+
+Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
+
### Config backups
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
@@ -1047,7 +1073,7 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
-**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar also falls back (the whole first line would drop below the toolbar). A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
+**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
@@ -1364,3 +1390,16 @@ refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
updates `lastSubmitAt` just like any other Enter.
+
+## Discord bridge
+
+`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md).
+
+- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`.
+- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state.
+- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false.
+- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded.
+- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires.
+- **Files.** Received files come only from Discord's HTTPS hosts into `/.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/custom-model-endpoints-plan.md b/docs/custom-model-endpoints-plan.md
index 5078f17b..aec0b910 100644
--- a/docs/custom-model-endpoints-plan.md
+++ b/docs/custom-model-endpoints-plan.md
@@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's
OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway).
Right now every Codeman session mode defaults to its native cloud backend
with no way to redirect a session at any other endpoint from the UI — the
-closest existing precedent is DeepSeek's server-env-sourced
-`DEEPSEEK_BASE_URL`, which isn't user-facing.
+closest existing precedent was DeepSeek's server-env-sourced
+`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
+been superseded by deepseek's per-session patch recipe, see the table
+below).
**Scope note**: this plan originally said "local LLM." It now covers any
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
@@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch.
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
-| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST /chat/completions` → `404`, `POST /v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
+| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md
index f76c21a1..c5f0f427 100644
--- a/docs/custom-model-endpoints.md
+++ b/docs/custom-model-endpoints.md
@@ -67,15 +67,23 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
Endpoint management is admin-only in multi-user mode, same as remote/docker
hosts — these are machine-level infra, not per-user settings.
-**Context length is discovered too, opportunistically and safely.** The plain
-`GET /v1/models` response has no context-window field. Discovery only ever
-looks for one for a model llama-swap's own response already reports
+**Context length is discovered too, opportunistically and safely.** Older
+servers state no context-window field in the plain `GET /v1/models`
+response at all; current llama-swap states one per model (both
+`context_length` and `context_window`) and gateways like Bifrost pass
+`context_window` through: that listed figure is the *lowest*-precedence
+source, used only when neither probe below answers, and because llama-swap
+documents it as the PER-SLOT window, a model whose figure comes from the
+listing is recorded with a slot count of 1 so the slot check never divides a
+per-slot figure a second time. The probes are only ever looked for on a model
+llama-swap's own response already reports
`status.value === "loaded"` for — never for an unloaded one, because
llama-swap treats `?model=` as a routing hint and asking about a model that
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
effect of what should be read-only discovery. A server with no `status` field
-on any entry at all (not llama-swap) gets no context-length enrichment,
-rather than guessing. A model's previously-learned context length survives a
+on any entry at all (not llama-swap) gets no launch-command/`/props`
+enrichment, only whatever its listings state, rather than guessing. A
+model's previously-learned context length survives a
later cycle where it wasn't the loaded one; it's dropped only once the model
disappears from the endpoint's list entirely. Stored per model in
`modelContextLengths` and applied automatically (see "Applying a model to a
@@ -192,32 +200,66 @@ saved default, not just confirming it.
The modal promotes exactly one row to the top of the list rather than
always showing raw discovery order, so the zero-wait choice is the one
-under your thumb:
+under your thumb. Three kinds of knowledge feed that promotion, best
+evidence first, and the first one that answers wins:
-- **"Currently loaded"** — a model from this host's own list that
+- **"Currently loaded"** is a model from this host's own list that
llama-swap reports `ready` right now, queried via
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
~800ms (`Promise.race`), on top of the route's own 5s server-side
timeout, so an endpoint that is asleep or firewalled cannot leave the
modal invisible for the full 5s after the Run menu has already closed.
-- **"Last used"** — shown only when nothing is currently loaded: the model
- actually launched last for this exact (harness, endpoint) pair, read
- from the per-device `codeman:customModelLastUsed::`
- localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
- and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
+- **"In use by a session"** is the fallback for every endpoint that
+ cannot report its own loaded model, which is every server except
+ llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as
+ Bifrost fronting a whole fleet all answer `isLlamaSwap: false`.
+ `_getCustomModelInUseBySession` walks `this.sessions` for one of your own
+ sessions whose `customModel.endpointId` is this host, newest
+ `createdAt` wins, and promotes its model. A live session is first-hand
+ evidence of a model in use on the endpoint, because Codeman put it
+ there, so it earns the promotion and the tag says where the knowledge
+ came from. Two fences keep it honest: it reads only `customModel`,
+ never `displayModel` (display text scraped off a CLI footer, so a
+ terminal that merely printed a similar string must not mark a row),
+ and a session with `status: 'stopped'`, a null `pid` (its PTY exited,
+ the same live-pane test keep-warm applies) or a recorded `paneExit`
+ does not count, since nothing is on the endpoint any more. Sessions on other
+ endpoints, and models the host no longer discovers, are skipped.
+- **"Last used"** appears only when neither of the above answers: the
+ model actually launched last for this exact (harness, endpoint) pair,
+ read from the per-device localStorage key
+ `codeman:customModelLastUsed::`. That key is per CLI
+ on purpose, so it can only
+ ever promote the CLI that launched there before, which is why a gateway
+ box with no session running shows plain discovery order for the other
+ CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and
+ `_quickStartWithCustomModelConfirm` (every one-shot launch; the
`runCustomModelEntry` entry point itself only dispatches between the
- two) only once the model is actually applied, never on the mere click —
+ two) only once the model is actually applied, never on the mere click:
declining the context-window warning means this exact model cannot work
- with this CLI at all, so promoting it next time would be actively wrong,
- not just premature.
+ with this CLI at all, so promoting it next time would be actively
+ wrong, not just premature.
-Neither tag reorders anything past that one promoted row. The "Default"
+No promotion reorders anything past that one promoted row. The "Default"
pill is a separate span, not a third value of the same slot: a promoted
row that is also the endpoint's `defaultModelId` shows both tags (on a
single-purpose GPU box that is the common case, and an exclusive slot
silently dropped the Default marking for exactly that row), and a row
with neither promotion nor default shows no tag at all.
+When nothing is known about any row, `#customModelPickUnknown` says so
+out loud instead of leaving an alphabetical wall that reads as the app
+ignoring the question. It shows when the probe answered
+`isLlamaSwap: false`, which the route gives for every failure it
+catches, so one answer covers a server that cannot report and an
+endpoint that is down or refused alike; the line's wording therefore
+claims only that nothing was reported. A probe that never got any
+answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own
+request failed or the client-side bound won) keeps the line hidden.
+Like its sibling no-matches line it needs an
+explicit `[hidden]` rule scoped to `#customModelPickModal`, because
+`.form-hint`'s `display: block` outranks the UA `[hidden]` rule.
+
**How the launch itself applies the endpoint depends on the harness.** For
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
@@ -286,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume ||
grok are relaunched with the `--model` value that selects the injected
provider (`custom/` for pi and omp, `codeman-custom` for grok),
since for those three the config file alone does not switch the model.
+**Codex is relaunched with `--model ` too**, the bare selected id
+(not a provider selector): the launch param forces the session onto the
+model its generated `config.toml` already selected, so a launch-time model
+carried over from App Settings can never override the injected selection.
+That launch arg must satisfy the codex entry's `model-plain-colon` token
+pattern (a leading letter or digit, then letters, digits and `._-:/`, per
+`stock.ts`), so an id outside that shape, e.g. LM Studio's
+`org/model@q4_k_m`, is refused with a 400 at apply time now, where a
+`config.toml` alone used to carry it.
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
reattaches the durable remote/in-container tmux rather than relaunching the
agent, so the selection would report success and change nothing.
@@ -293,14 +344,16 @@ agent, so the selection would report success and change nothing.
**Claude gets two more env vars when known/applicable, both declared on its
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
-- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
- length (see the discovery section above) whenever one is known. Without
+- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
+ the discovered figure (see the discovery section above), falling back to
+ the endpoint's user-set `defaultContextLength`, whenever one is known.
+ Without
it, Claude Code assumes a large (200k) window for any unrecognized custom
model id and never compacts, which reliably overflows a much smaller real
local context — confirmed live: a stock ~33.7K-token system prompt against
a 16384-token llama-swap model failed with `exceeds the available context
-size`. No entry for the model in `modelContextLengths` means the var is
- simply omitted, never a guess. ⚠️ **This var only affects when Claude
+size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
+ for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude
Code compacts conversation _history_ — it cannot fix a model whose real
context is smaller than Claude Code's own fixed per-turn overhead**
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
@@ -441,15 +494,17 @@ Both apply routes (the restart route and the one-shot `POST
/api/quick-start`) now check for this **before** launching or restarting
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
(currently only claude — the check is a no-op for every other CLI by
-construction, never a hardcoded mode check). If the model's discovered
-context (`modelContextLengths`, from discovery above) is below
-`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
+construction, never a hardcoded mode check). If the model's context (the
+discovered figure from `modelContextLengths`, or the endpoint's
+`defaultContextLength` when discovery never learned one for that model) is
+below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is
-restarted or created yet. A context length that was never discovered at
-all skips the check entirely (nothing to compare, so it fails open rather
-than warning on every model an endpoint hasn't reported a size for).
-The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
+restarted or created yet. A model with neither a discovered figure nor a
+`defaultContextLength` skips the check entirely (nothing to compare, so it
+fails open rather than warning on every model an endpoint hasn't reported a
+size for).
+The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
The Run-menu picker shows this as an in-app modal
(`#customModelContextWarningModal`, matching the llama-swap conflict
@@ -526,7 +581,12 @@ for '' not found. Defaulting to fallback metadata...` on launch —
and fabricating a fake catalog entry would mean copying the _shape_ of
OpenAI's own proprietary schema (their per-model system-prompt content
included) for a warning that doesn't otherwise affect behavior — not
- something to build into discovery.
+ something to build into discovery. ⚠️ The fallback-metadata warning is
+ NOT about the context window going un-overridden. Codex does now get a
+ real context-window override: when a figure is known the generated
+ `config.toml` sets the top-level `model_context_window` to it and
+ `model_auto_compact_token_limit` to 75% of it (floored), so Codex
+ compacts at that point instead of trusting its fallback guess.
- **Gemini** — fails with `Invalid auth method selected`, traced to an
undocumented `GATEWAY` auth path gemini-cli selects once
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
@@ -541,14 +601,40 @@ for '' not found. Defaulting to fallback metadata...` on launch —
`POST /chat/completions` 404s against llama-swap while
`POST /v1/chat/completions` succeeds; the harness's own error
template (`DeepSeek API error (HTTP ${status})`) matches the originally
- reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
- (deepseek's entry only — claude/gemini must NOT get it, since claude was
- already confirmed working against the raw `baseUrl`) fixes it by writing
- `DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
- real `dsh` binary (no install available in this environment) — the fix
- is source-confirmed and live-verified at the HTTP level, but a real
- "hello world" reply through `dsh` itself is still outstanding before
- calling this fully verified like the harnesses above.
+ reported symptom exactly. The fix is no longer the original env redirect:
+ deepseek's `customModelInjection` is a `configDir` recipe: a per-session
+ `DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
+ `codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
+ suffix from the shared `renderConfigFile` step every file template goes
+ through), so the installed profile keeps supplying the transport; the
+ recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
+ `settings.yaml` back out of the real dsh home so installed bundles,
+ transcripts and user settings all survive the redirect (missing
+ directory entries are created first: a home that has never run a
+ turn would otherwise get a dangling link whose every write through
+ it failed with ENOENT). **Verified end-to-end against a real dsh
+ install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
+ `--dump-config` shows the three patched rows, the TUI boots on the
+ chosen model, and a prompt reaches the endpoint as
+ `POST /v1/chat/completions` with the right model, the bearer key and
+ `max_tokens: 8192`; the reply renders and the context window shows in
+ the footer. What such a session SHARES with the real home is
+ exactly the linked entries: `profiles/`, `sessions/`,
+ `settings.yaml`. The last is shared only until the session writes
+ it: dsh saves settings with a temp file and a rename
+ (`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
+ the symlink, so the first `/settings` change inside such a session
+ never reaches `~/.dsh/settings.yaml` and is deleted with the session's
+ directory on clear or close. Everything else in the generated `DSH_HOME` is
+ isolated: `storages/` (dsh-TUI's persisted `/model` choice and
+ workspace state), `.agent-presets` and `.env`. Two consequences of
+ the generated home-level file follow: the user's own home-level
+ `cordis.patch.yml` does NOT apply in such a session (the generated
+ file replaces it; profile-level patch files reached through the
+ linked `profiles/` still compose), and the generated patch REPLACES
+ the `llm-pi-ai` provider row's whole `config` for the session. Note
+ too that it likewise REPLACES the `dsh-tui` row's whole `config`, so
+ a profile-level `effort`/`preset` does not apply.
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md
new file mode 100644
index 00000000..86210d3e
--- /dev/null
+++ b/docs/discord-bridge.md
@@ -0,0 +1,137 @@
+# Discord bridge
+
+Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network.
+
+> [!WARNING]
+> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password.
+
+## What it does
+
+- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, 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`; Codex's permission and question dialogs are also read off its screen.
+- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
+- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
+- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
+- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
+- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file `.
+- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)).
+
+## Setup
+
+Do these once, in order. Never paste the bot token into a chat or a session.
+
+### 1. Create a private server
+
+1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**.
+2. Invite nobody and create no invite links.
+3. Create one text channel, for example `#codeman-cli`.
+
+### 2. Copy the IDs
+
+1. Open **User Settings**, **Advanced** and turn on **Developer Mode**.
+2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID.
+
+### 3. Create the application
+
+1. Go to and choose **New Application**.
+2. Name it `Codeman`.
+
+### 4. Configure the bot
+
+On the **Bot** tab:
+
+1. Choose **Reset Token** and copy it. It is shown once.
+2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here.
+3. Leave **Requires OAuth2 Code Grant** off.
+4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off.
+
+### 5. Invite the bot with minimal permissions
+
+On **OAuth2**, **URL Generator**:
+
+1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else.
+2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview):
+
+ | Permission | Why |
+ | ------------------------ | ------------------------------------ |
+ | View Channels | See the parent channel |
+ | Send Messages | Post in the parent channel |
+ | Send Messages in Threads | Post in each tab's thread |
+ | Create Public Threads | Make a thread per tab |
+ | Read Message History | Read your replies |
+ | Add Reactions | Acknowledge a reply that was typed |
+ | Attach Files | Send a file from a CLI to the thread |
+ | Embed Links | Show previews for links in posts |
+
+3. Open the generated URL, choose your private server and authorise.
+4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot.
+
+The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`.
+
+### 6. Optional: allow deleting threads
+
+Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads.
+
+### 7. Never grant
+
+Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission.
+
+### 8. Configure Codeman
+
+1. Open **Settings**, **Notifications**, then the **Discord** group.
+2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist.
+3. Choose the options below and press **Send test**. A test message should appear in the channel.
+
+The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE.
+
+### 9. Phone alerts
+
+In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network.
+
+## Options
+
+| Option | Values | Default | Effect |
+| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
+| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
+| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. 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 |
+
+## Telling the CLIs
+
+A CLI only posts deliberately if it knows it can. While the bridge is on:
+
+- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway.
+- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to.
+
+The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those.
+
+## Files
+
+- **Received:** files you attach to a reply from an allowed user are saved as `/.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. 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 ` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files.
+- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it.
+
+## Security model
+
+- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped.
+- A reply is reduced to one line, stripped of control characters and capped in length before it is typed.
+- Replies are rate limited per session.
+- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings.
+
+## Rotating and revoking
+
+- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**.
+- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts.
+- If the token is ever pasted somewhere it should not be, reset it straight away.
+
+## Troubleshooting
+
+| Symptom | Check |
+| ----------------------------------- | ------------------------------------------------------------------------ |
+| Test fails with 401 | The token is wrong or was reset. Paste the current one |
+| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID |
+| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist |
+| Thread is not deleted | The bot lacks Manage Threads on the parent channel |
+| Nothing arrives on the phone | Check the Discord app's notification setting for the server |
diff --git a/docs/releasing.md b/docs/releasing.md
index fc1f7fe5..097a03a9 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -59,4 +59,4 @@ A security notice is a sticky warning card about something outside Codeman that
- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one.
- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one).
-**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload.
+**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file.
diff --git a/docs/security-architecture.md b/docs/security-architecture.md
index 0a6eb6ea..72c2dcf0 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 by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. 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 and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file 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; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
---
diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md
index 8ca07216..e50d0739 100644
--- a/docs/wiki/Config-Backups.md
+++ b/docs/wiki/Config-Backups.md
@@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o
| `clis.json` | CLI enable/disable and custom CLI entries. |
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
| `webhook.json` | Webhook notification target. |
+| `discord.json` | Discord bridge settings. **Holds a bot token.** |
| `intents.json` | Read My Mind per-case profiles. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
@@ -188,15 +189,15 @@ that are the same are listed on one line. A file that is not JSON shows only tha
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
self-rewritten fields listed under "When a backup is made" are not counted as changes.
-**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the
-URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
+**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the
+bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
query and fragment.
## Security
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
- `push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default
+ `push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
location exposes nothing the data directory does not already.
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
network share copies those credentials there, with whatever permissions that location has.
diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md
index c5762d60..1b2428a0 100644
--- a/docs/wiki/Core-Concepts.md
+++ b/docs/wiki/Core-Concepts.md
@@ -22,11 +22,11 @@ Codeman-side configuration:
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
-| How | Result |
-| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Create New** | A fresh `~/codeman-cases/` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
-| **Clone Repo** | A repo cloned into `~/codeman-cases/` and registered as a case. Private repos need this machine's own git credentials (see below). |
-| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
+| How | Result |
+| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Create New** | A fresh `~/codeman-cases/` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
+| **Clone Repo** | A repo cloned into `~/codeman-cases/` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
+| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
registration, and for a linked case that is all it removes.
diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md
index 9d2b6a30..89b51068 100644
--- a/docs/wiki/Custom-Model-Endpoints.md
+++ b/docs/wiki/Custom-Model-Endpoints.md
@@ -27,14 +27,20 @@ hosts — these are machine-level infra, not a per-user setting.
shows up without another manual click of **Discover**. One endpoint being unreachable on a
given cycle (powered off, wrong network) never blocks the others from refreshing.
-**Context length is picked up automatically where it can be, safely.** Against a
-llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
-context window and applies it to the launched session (Claude Code today — see below), so
-the harness stops assuming a large default window for a model name it doesn't recognise and
-overflowing a much smaller real one. It's deliberately never probed for a model that isn't
-already loaded, since asking a llama-swap server about an unloaded model can trigger an
-actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
-context length an earlier cycle already learned for it instead.
+**Context length is picked up automatically where it can be, safely.** Every model's own
+listing entry is read for a stated context figure (`context_length`, then `context_window`,
+which current llama.cpp, llama-swap and gateway-style servers state to varying degrees);
+against a llama-swap server the stronger figures from its launch command and from llama.cpp's
+`/props` probe win where they exist, and those probes only ever run for a model the server
+already reports loaded, since asking about an unloaded model can trigger an actual, slow model
+swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned:
+the stored figure always beats the endpoint's **Default context length** field
+(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was
+ever learned (that number is the model's whole window, never a per-slot figure). A model
+neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses
+that can take it: Claude Code today (see below), OpenCode as the model's context limit, and
+Codex as a `model_context_window` entry in its generated config file, which also sets Codex's
+own auto-compact point via `model_auto_compact_token_limit` at 75% of the window.
## Running a session against one
@@ -48,15 +54,20 @@ pointed at.
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
the session launches straight away on that model — nothing to choose. With two or more, a
-small dialog asks which one to use for this launch before starting the session; the
+small dialog asks which one to use for this launch before starting the session; a search box
+at the top narrows a long list with a case-insensitive substring match, so a server hosting
+hundreds of models stays usable. The
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
use a different one without changing the saved default. The list is not raw discovery order
either: the model llama-swap reports loaded and ready is moved to the top and tagged
-**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
-and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
-another device starts from its own history). The default model keeps its own **Default** pill
-in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
-thumb.
+**Currently loaded**. When nothing is reported loaded, the model one of your own running
+sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it
+beats **Last used**, never **Currently loaded**), and failing that the model you last launched
+on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser
+value, so another device starts from its own history). With none of those, a line under the
+search box says nothing is marked, so the plain list is not mistaken for an answer. The default
+model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the
+promoted row is simply the one under your thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
straight onto the endpoint** — no restart, because the endpoint is applied before the
@@ -186,7 +197,7 @@ read is treated as one slot, never guessed.
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
-| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
+| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. |
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md
new file mode 100644
index 00000000..1694a04f
--- /dev/null
+++ b/docs/wiki/Discord-Bridge.md
@@ -0,0 +1,105 @@
+# Discord Bridge
+
+Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord
+server when a session needs you, gives every tab its own thread, and types your reply in that
+thread into that tab. It only makes outbound connections, so nothing is exposed to the internet
+and it works when you are away from your network.
+
+**Opt-in, off by default. One server, one channel, threads per tab.**
+
+> **Treat the Discord account like a login to the machine.** A reply from an allowed account is
+> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password.
+
+## What it does
+
+- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A
+ question or permission prompt shows what is on the tab's screen right now, so you can see what is
+ being asked; a finished turn or an error carries the CLI's whole last message.
+- **One thread per tab** in a parent channel, so a reply always reaches exactly one session.
+- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter).
+- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can
+ send a file back.
+- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent`
+ posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents).
+
+## Set it up
+
+The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token
+handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md).
+In short:
+
+1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs.
+2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and
+ invite it with the `bot` scope and only the permissions the guide lists (no Administrator).
+3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**,
+ then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again.
+
+The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner
+only), never in the shared settings, and never shown again once saved.
+
+## Options
+
+| Option | Choices | Default |
+| ---------------------------------------- | ------------------------------------------- | --------------- |
+| Create threads | On the first alert, or for every new tab | First alert |
+| When a tab is deleted | Keep, archive or delete the thread | Archive |
+| Stale threads | Keep, archive or delete threads no tab uses | Keep |
+| @ ping | Never, critical events only, every post | Critical only |
+| Which events | Needs attention, or everything | Needs attention |
+| Tell Claude how to reach you | On or off | On |
+
+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 (Codex, OMP, 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.
+
+The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a
+deleted tab's thread leaves the bridge's map.
+
+Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
+permission prompt, a question, an error, or a post an agent marked urgent.
+
+## Telling the agents
+
+An agent only posts deliberately if it knows it can:
+
+- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
+- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to.
+
+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. The folder ignores itself, so nothing in it is committed.
+- **Agent to you:** `codeman notify "message" --file ` uploads a file of at most 8 MB that lies
+ inside the tab's folder. Credential files such as `.env` are refused.
+- Remote (ssh) tabs have no local folder, so they neither send nor receive files.
+- A file you send is untrusted content to the agent that opens it.
+
+## Safety
+
+- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on.
+ Everything else is dropped.
+- A reply is cut to one printable line (1000 characters) and limited to one per second per tab.
+- Posts can ping only the allowlisted users, so agent output cannot ping the whole server.
+- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings.
+
+## Troubleshooting
+
+| Symptom | Check |
+| ------------------------------------------------ | -------------------------------------------------------------- |
+| Send test says 401 | The token is wrong or was reset. Paste the current one. |
+| Send test says the bot cannot see it | Check the bot's role and the channel ID. |
+| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. |
+| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. |
+| A thread is not deleted | The bot lacks Manage Threads on the channel. |
+
+See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear
+from an agent, and [Security](Security) for how secrets are handled.
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 3a0cd566..0efad3c4 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000
| ------------------------------------------------------------- | --------------------------------------------------- |
| [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? |
| [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? |
+| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? |
| [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? |
| [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? |
| [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? |
diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md
index fcdd7f93..50dad790 100644
--- a/docs/wiki/Notifications-And-Approvals.md
+++ b/docs/wiki/Notifications-And-Approvals.md
@@ -13,6 +13,7 @@ opening the session.
| Desktop notification | Another window on the same machine | Opt-in |
| Push notification | Anywhere, even with no tab open | Opt-in |
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
+| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in |
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
| Phone overview | Phone home screen, NEEDS YOU section | On |
| Away Digest | Afterwards, as a summary | Opt-in |
@@ -66,6 +67,11 @@ Setup:
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
+## Discord bridge: answer from your phone
+
+A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a
+thread per tab, your reply typed into that tab, files in both directions.
+
## Webhooks: ntfy, Slack, Discord
**Opt-in, off by default. One channel for the whole server.**
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index 17cd6555..1a0b3a1e 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -62,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
-Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
+Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
@@ -106,6 +106,12 @@ nothing waits for a login, and that is a default, never an override: your own `G
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
+**Edit history** (per device, off by default, never on phones) adds a header button for the
+active session's edit history: every edit the agent and its subagents made, with its diff, and
+images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
+stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
+[Working With Files](Working-With-Files#edit-history).
+
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
@@ -214,8 +220,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser
notification display time**, default 8; both per device, and your OS may close a
desktop notification sooner), the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
-webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
-[Notifications And Approvals](Notifications-And-Approvals).
+webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the
+Discord group for the two-way bot (token, IDs, thread and ping options; see
+[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals).
### Voice
diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md
index eb5611b8..9486ac69 100644
--- a/docs/wiki/Working-With-Files.md
+++ b/docs/wiki/Working-With-Files.md
@@ -87,7 +87,10 @@ detected at once would fork ten multi-minute converter processes.
File paths in a session are links. That works in two places:
-- **In the terminal**, on any absolute path an agent prints.
+- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`,
+ `../notes.md`) resolves against the directory the session's pane is in now, so after a `cd`
+ in a shell it opens the file there; until the pane reports a directory, and always for remote
+ SSH and Docker cases, it resolves against the session's working directory.
- **In the response viewer**, where paths are usually written as prose or in backticks. They
render as underlined monospace links. A Markdown link whose target is a path
(`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way.
@@ -168,6 +171,11 @@ choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
+In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you
+are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting
+with a dot or `~`). The cases folder is refused, since every folder there is already a
+case: use **Add Case → New Case** instead.
+
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
@@ -272,6 +280,60 @@ data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-d
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
+## Edit history
+
+The Git panel shows what is different now. The edit history shows what the agent did, one edit
+at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
+Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
+header next to Attachments. It is not shown on phones.
+
+Click it for a draggable window listing the active session's edits, newest first:
+
+- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
+ and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
+ Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
+- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
+ its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
+ their file, with the totals on the file's row.
+- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
+ a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or
+ its output counts when the file's modification time falls within that command's run. Images the
+ agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one
+ to open it in the preview.
+- **Load older** reads further back in the conversation, in the main agent's transcript and the
+ subagents' alike (up to three transcripts per click; with more, they take turns).
+- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window
+ first catches up on the ones it had not read yet. **Load older** then goes back within the new
+ conversation.
+
+It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
+
+How it reads:
+
+- **Only while you look.** The history is read from the session's Claude transcript when you open
+ the window, and again after each tool call that can change files and at the end of each turn,
+ while it stays open on that session. A closed window reads nothing, and Codeman does no work in
+ the background for sessions nobody looks at.
+- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
+ session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
+ the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
+ directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
+ is only known after the next prompt; until then the window says "No transcript found for this
+ session yet".
+- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
+ `~/.codeman/edit-history/.json`, including API keys or `.env` contents it edited. The file is
+ readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
+ are dropped), never part of search, blocked from every file route, and deleted when you delete
+ the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and
+ delete it.
+- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
+ Viewer's route. An image outside it opens like any clicked path outside the workspace (no
+ notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
+
+The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
+`POST /api/sessions/:id/edits/older` (see the
+[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
+
## Gotchas
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md
index 0b85b925..36a04909 100644
--- a/docs/wiki/_Sidebar.md
+++ b/docs/wiki/_Sidebar.md
@@ -25,6 +25,7 @@
- [Unattended Runs](Keeping-Agents-Running)
- [Notifications & Approvals](Notifications-And-Approvals)
+- [Discord Bridge](Discord-Bridge)
- [Cron Jobs](Cron-Jobs)
- [Autonomous Loops](Autonomous-Loops)
- [Watching Agents Work](Watching-Agents-Work)
diff --git a/package-lock.json b/package-lock.json
index 1d6ed658..ff1b96da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
- "version": "1.47.0",
+ "version": "1.48.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
- "version": "1.47.0",
+ "version": "1.48.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -63,7 +63,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
- "playwright": "^1.58.0",
+ "playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
@@ -9736,50 +9736,32 @@
}
},
"node_modules/playwright": {
- "version": "1.60.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
- "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.60.0"
+ "playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
+ "node": ">=20"
}
},
"node_modules/playwright-core": {
- "version": "1.60.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
- "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
- "node": ">=18"
- }
- },
- "node_modules/playwright/node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">=20"
}
},
"node_modules/pngjs": {
diff --git a/package.json b/package.json
index 948c1cba..d468ef30 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
- "version": "1.47.0",
+ "version": "1.48.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
@@ -133,7 +133,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
- "playwright": "^1.58.0",
+ "playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json
index 8cfa4337..69089733 100644
--- a/plugins/codeman/.claude-plugin/plugin.json
+++ b/plugins/codeman/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
- "version": "1.47.0",
+ "version": "1.48.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/src/cli-notify.ts b/src/cli-notify.ts
new file mode 100644
index 00000000..5cdae57a
--- /dev/null
+++ b/src/cli-notify.ts
@@ -0,0 +1,129 @@
+/**
+ * @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
+ *
+ * A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
+ * wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
+ * session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
+ * print and the exit code instead of exiting, so a test can drive it.
+ *
+ * @module cli-notify
+ */
+
+import http from 'node:http';
+import https from 'node:https';
+import { isAbsolute, join } from 'node:path';
+import { readCodemanCredentials } from './codeman-credentials.js';
+
+const REQUEST_TIMEOUT_MS = 30_000;
+
+export interface NotifyOptions {
+ message: string;
+ urgent?: boolean;
+ file?: string;
+ session?: string;
+ url?: string;
+}
+
+export interface NotifyResult {
+ code: number;
+ out?: string;
+ err?: string;
+}
+
+export type JsonPost = (
+ apiUrl: string,
+ apiPath: string,
+ payload: unknown,
+ timeoutMs?: number
+) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
+
+/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
+export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
+ const { username, password } = readCodemanCredentials();
+ const url = new URL(apiPath, apiUrl);
+ const body = JSON.stringify(payload);
+ const transport = url.protocol === 'https:' ? https : http;
+ return new Promise((resolveOnce) => {
+ // Settle once: a response that stalls after its headers ends in 'close' without 'end'.
+ let settled = false;
+ const resolve: typeof resolveOnce = (v) => {
+ if (settled) return;
+ settled = true;
+ resolveOnce(v);
+ };
+ const headers: Record = {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ };
+ if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
+ const req = transport.request(
+ {
+ protocol: url.protocol,
+ hostname: url.hostname,
+ port: url.port,
+ method: 'POST',
+ path: `${url.pathname}${url.search}`,
+ rejectUnauthorized: false,
+ headers,
+ timeout: timeoutMs,
+ },
+ (res) => {
+ let text = '';
+ res.on('data', (c) => (text += c));
+ res.on('error', () => resolve({ status: 0 }));
+ res.on('close', () => resolve({ status: 0 }));
+ res.on('end', () => {
+ let error: string | undefined;
+ let delivery: { ok: boolean; error?: string } | undefined;
+ try {
+ const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
+ error = body.error;
+ // A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
+ if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
+ } catch {
+ /* not JSON */
+ }
+ resolve({ status: res.statusCode ?? 0, error, delivery });
+ });
+ }
+ );
+ // A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
+ req.on('timeout', () => req.destroy());
+ req.on('error', () => resolve({ status: 0 }));
+ req.write(body);
+ req.end();
+ });
+};
+
+export async function runNotify(
+ opts: NotifyOptions,
+ env: NodeJS.ProcessEnv,
+ cwd: string,
+ post: JsonPost = postJson
+): Promise {
+ const sessionId = opts.session || env.CODEMAN_SESSION_ID;
+ const apiUrl = opts.url || env.CODEMAN_API_URL;
+ if (!sessionId || !apiUrl) {
+ return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
+ }
+ const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
+ if (opts.urgent) payload.urgent = true;
+ if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
+ const { status, error, delivery } = await post(
+ apiUrl,
+ `/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
+ payload
+ );
+ if (status >= 200 && status < 300) {
+ if (delivery?.ok === false) {
+ return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
+ }
+ return { code: 0, out: 'Posted to Discord' };
+ }
+ // The bridge being off is not a failure: the caller just says it in the terminal.
+ if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
+ if (status === 429)
+ return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
+ return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
+}
diff --git a/src/cli.ts b/src/cli.ts
index 4682aa89..b28ee6a0 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
+import { runNotify } from './cli-notify.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import {
@@ -92,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
});
}
+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 { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
+ if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
+ if (err) console.error(palette.err(`✗ ${err}`));
+ process.exit(code);
+ });
+
program
.command('attach ')
.description('Show an attachment card for a local file')
diff --git a/src/config-backup.ts b/src/config-backup.ts
index f9e315aa..e3425dc9 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/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts
index 9cd8f303..612d3783 100644
--- a/src/config/cli-registry/patterns.ts
+++ b/src/config/cli-registry/patterns.ts
@@ -21,6 +21,7 @@ export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
+ | 'model-plain-colon'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -47,6 +48,10 @@ const PATTERNS: Record = {
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
+ // codex --model, and the value a custom-model `launchModel` is forced onto it. Same
+ // shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
+ // `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
+ 'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts
index 461a81fb..ff3f40d5 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',
]),
@@ -286,6 +288,14 @@ const launchModelTemplate = z
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
.optional();
+/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
+ * leading alphanumeric, no traversal or hidden-dot escape. */
+const homeSegmentName = z
+ .string()
+ .min(1)
+ .max(40)
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
+
const capabilitiesSchema = z
.object({
external: z.boolean(),
@@ -584,10 +594,61 @@ const capabilitiesSchema = z
kind: z.literal('configDir'),
dirEnvVar: envName,
fileName: z.string().min(1).max(80),
- template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
+ template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
launchModel: launchModelTemplate,
+ // State that must survive the isolated-directory redirect, symlinked by name from
+ // the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
+ // segments only: the consumer joins them, so a traversal here would escape the
+ // real home. See the field's comment in cli-registry/types.ts.
+ linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
+ // Which of `linkFromRealHome` are directories (missing ones are created before
+ // linking; missing files are skipped). Same segment pattern, see types.ts.
+ linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
+ // The real home's fallback location, relative to the user's home, ONE segment:
+ // a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
+ // bare `.` are not; the consumer joins it under the user's home, a traversal
+ // here escapes that home and `.` IS that home (linking THROUGH it unshielded).
+ realHomeFallback: z
+ .string()
+ .min(1)
+ .max(80)
+ .regex(/^(?!\.\.?$)[^/\\]+$/)
+ .optional(),
})
- .strict(),
+ .strict()
+ .superRefine((recipe, ctx) => {
+ // Both rules were comments before; the schema is the security boundary, so
+ // enforce them at load time (see the header of this file).
+ if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'realHomeFallback is required whenever linkFromRealHome is declared',
+ path: ['realHomeFallback'],
+ });
+ }
+ for (const name of recipe.linkFromRealHome ?? []) {
+ if (name === recipe.fileName) {
+ ctx.addIssue({
+ code: 'custom',
+ message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
+ path: ['linkFromRealHome'],
+ });
+ }
+ }
+ // types.ts documents the dirs list as "must be a subset of linkFromRealHome";
+ // a name absent from it names nothing (missing-file entries are skipped as
+ // files), so the recipe would silently stop creating a directory it declares.
+ const linkedNames = new Set(recipe.linkFromRealHome ?? []);
+ for (const dir of recipe.linkFromRealHomeDirs ?? []) {
+ if (!linkedNames.has(dir)) {
+ ctx.addIssue({
+ code: 'custom',
+ message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
+ path: ['linkFromRealHomeDirs'],
+ });
+ }
+ }
+ }),
z.object({ kind: z.literal('unsupported') }).strict(),
]),
})
diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts
index feef3864..6e9aa225 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' },
+ },
],
},
],
@@ -629,7 +642,10 @@ const CODEX: CliEntry = {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
- model: { type: 'token', pattern: 'model' },
+ // `model-plain-colon`, not `model`: the custom-model feature forces the selected id
+ // onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
+ // `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
+ model: { type: 'token', pattern: 'model-plain-colon' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
@@ -887,6 +903,7 @@ const CODEX: CliEntry = {
dirEnvVar: 'CODEX_HOME',
fileName: 'config.toml',
template: 'codex-toml',
+ launchModel: '{modelId}',
},
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
// reachable via plain envOverrides before this feature existed. It is arguably
@@ -1591,30 +1608,30 @@ const DEEPSEEK: CliEntry = {
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
// privilege rather than granting it, and clamping it here was a real regression
- // (test/deepseek-mode.test.ts) fixed before this shipped.
- privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
- // Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
- // modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
- // }` above), not an env var, so forcing a specific model name may not fully work;
- // verify against a real profile before shipping.
- //
- // ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
- // 404s. Confirmed live and by reading dsh's own bundled source
- // (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
- // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
- // API, https://api.deepseek.com, expects the caller's base URL to already carry any
- // needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
- // "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
- // 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
- // matches dsh's own error-message template for exactly this failure. See the
- // customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
- // including why claude/gemini must NOT get this.
+ // (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
+ // joins it (like codex's entry): the dedicated custom-endpoint key rides the same
+ // allowlisted-envOverrides path as the redirect itself.
+ privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
+ // The installed profile supplies the transport; a per-session patch selects its exact model.
customModelInjection: {
- kind: 'env',
- baseUrlVar: 'DEEPSEEK_BASE_URL',
- apiKeyVar: 'DEEPSEEK_API_KEY',
- modelVars: [],
- appendV1Suffix: true,
+ kind: 'configDir',
+ dirEnvVar: 'DSH_HOME',
+ fileName: 'cordis.patch.yml',
+ template: 'deepseek-patch-json',
+ // dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
+ // session-log root (deleting it would take the user's transcripts with it on clear/
+ // close), `profiles` are the installed bundles the patch composes over, and
+ // `settings.yaml` must keep applying (read through the link only until the session's
+ // first settings write: dsh saves by rename, which replaces the link with a local file
+ // that goes away with the session). The generated home-level `cordis.patch.yml`
+ // deliberately replaces the user's own for the session rather than linking it.
+ // `sessions` and `profiles` are directories: dsh creates them itself on a first
+ // turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
+ // dangling link whose every write fails ENOENT: the consumer creates missing
+ // directory targets and skips missing file targets like `settings.yaml`.
+ linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
+ linkFromRealHomeDirs: ['profiles', 'sessions'],
+ realHomeFallback: '.dsh',
},
},
overlays: {
@@ -1967,8 +1984,8 @@ const COPILOT: CliEntry = {
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
- // The OpenAI-style route is `/v1/chat/completions`, so the base gets the `/v1` suffix
- // the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
+ // The OpenAI-style route is `/v1/chat/completions`, so the base gets the `/v1`
+ // suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
// they are privileged env keys below: a non-granted multi-user owner must not be able to
// redirect the session's endpoint or credentials through plain envOverrides.
customModelInjection: {
diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index 155e4912..ebf715d8 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'
/**
@@ -812,9 +814,11 @@ export interface CliCapabilities {
* for pi and omp (`--model custom/`, or the CLI stays on its own default
* provider and reports "No API key found for the selected model") or for
* grok (`--model codeman-custom`, the `[model.]` block the config
- * declares). Absent = the config alone selects the model (claude's env vars,
- * opencode's blob, codex's top-level `model` key). Applied by the session's
- * respawn options through the entry's `legacyConfigField`, never by id.
+ * declares), and codex carries its bare selected id (`{modelId}`, an id the
+ * entry's `model` token pattern must be able to carry). Absent = the config
+ * alone selects the model (claude's env vars, opencode's blob). Applied by
+ * the session's respawn options through the entry's `legacyConfigField`,
+ * never by id.
*
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
@@ -856,19 +860,13 @@ export interface CliCapabilities {
* reuses that file.
*
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
- * applied before being written to `baseUrlVar`, instead of being used verbatim.
- * DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
- * by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
- * bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
- * completions` with no `/v1` insertion of its own (its real public API base,
- * `https://api.deepseek.com`, expects the caller's base URL to already carry any
- * needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
- * `/v1/chat/completions` — confirmed live: a bare `POST /chat/completions`
- * 404s, `POST /v1/chat/completions` succeeds, and the harness's own error
- * message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
- * `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
- * by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
- * no suffix — appending one there would be wrong, not just redundant.
+ * applied before being written to `baseUrlVar`, instead of being used verbatim. Only
+ * copilot declares it: its OpenAI-style route is `/v1/chat/completions`, and a
+ * gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
+ * confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
+ * would be wrong, not just redundant. The per-session config-file recipes do not need
+ * this flag at all: `renderConfigFile()` applies the suffix inside every template
+ * (deepseek's patch included), so no configDir entry carries a raw base URL.
*/
customModelInjection:
| {
@@ -888,8 +886,40 @@ export interface CliCapabilities {
kind: 'configDir';
dirEnvVar: string;
fileName: string;
- template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
+ template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
launchModel?: string;
+ /**
+ * Names of entries under this CLI's REAL home that must be symlinked into the
+ * isolated directory after the generated file is written: the same trick
+ * `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
+ * home carries state that must survive the redirect (deepseek's `sessions` transcript
+ * root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
+ * applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
+ * A CLI that saves a linked file by rename replaces the link: dsh does, so the first
+ * in-session settings write turns `settings.yaml` into a local file.
+ * Never include `fileName` itself: the generated file owns that name in the isolated
+ * directory, and a link there would write THROUGH into the user's real file.
+ * Consumed generically by custom-model-injection-apply.ts.
+ */
+ linkFromRealHome?: string[];
+ /**
+ * Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
+ * files (`settings.yaml`). A missing directory target is created (mode 0700, what
+ * dsh does itself on a first turn) before linking: linking a nonexistent directory
+ * leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
+ * EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
+ * links serve no purpose the CLI could not do itself). Must be a subset of
+ * `linkFromRealHome`; entries absent from it are treated as files.
+ * Consumed generically by custom-model-injection-apply.ts.
+ */
+ linkFromRealHomeDirs?: string[];
+ /**
+ * Fallback location of the CLI's real home, relative to the user's home directory
+ * (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
+ * nor the server's environment sets it. Required whenever `linkFromRealHome` is
+ * declared, so no consumer has to hardcode a per-CLI default of its own.
+ */
+ realHomeFallback?: string;
}
| { kind: 'unsupported' };
}
diff --git a/src/config/path-probe.ts b/src/config/path-probe.ts
index e69e9642..c5a79141 100644
--- a/src/config/path-probe.ts
+++ b/src/config/path-probe.ts
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
+
+/**
+ * How long a session waits before trying again to resolve its workspace's symlinks
+ * after the folder did not answer, so a mount that recovers is picked up (the pane
+ * directory reading stays in the resolved spelling meanwhile).
+ */
+export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
diff --git a/src/custom-model-hosts.ts b/src/custom-model-hosts.ts
index 8ad2c17d..b6e6b7e7 100644
--- a/src/custom-model-hosts.ts
+++ b/src/custom-model-hosts.ts
@@ -52,18 +52,22 @@ export interface CustomModelHost {
defaultModelId?: string;
/**
* Discovered context-window size (tokens) per model id, keyed by the same strings as
- * `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
- * llama.cpp/llama-swap's `GET /props?model=` — the plain OpenAI-shaped `/v1/models`
- * response has no such field. Only ever probed for a model the server already reports as
- * loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
- * probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
- * actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
- * has no entry for simply gets no context-length env override applied — never a guess.
+ * `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest
+ * source the server offers: the llama-swap launch command reported by `GET /running`
+ * (the source of truth for a loaded model), then llama.cpp's `GET /props?model=`
+ * `n_ctx` (only ever probed for a model the server already reports as loaded, since
+ * llama-swap treats `/props?model=` as a routing hint that can trigger an actual
+ * (slow, GPU-swapping) model load as a side effect of merely asking), then the model's
+ * own `GET /v1/models` entry (`context_length`, else `context_window`, which current
+ * llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A
+ * model with no entry here falls back to the endpoint's `defaultContextLength`; with
+ * neither, it simply gets no context-length env override applied, never a guess.
*/
modelContextLengths?: Record;
/**
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
- * and populated on the same loaded-models-only basis. Read from the launch command's
+ * but populated only for models the server reports loaded (a listing never states a slot
+ * count, unlike `contextLengths`, which every entry can supply). Read from the launch command's
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
* the command cannot say). llama-server splits its context across these slots (by default
@@ -88,6 +92,29 @@ export interface CustomModelHost {
* server-side check relies on.
*/
modelSizesGB?: Record;
+ /**
+ * User-set fallback context length (tokens), applied via
+ * `resolveCustomModelContextLength()` at every read site (the swap route, the
+ * quick-start route, boot recovery) only when `modelContextLengths` has no entry for
+ * the selected model (a gateway that states no per-model figure, a model never loaded
+ * yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor
+ * check divides it by any stored slot count exactly like a discovered figure. Never
+ * written by discovery, so a value set in the endpoint editor survives every
+ * re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI
+ * assume its own default.
+ */
+ defaultContextLength?: number;
+}
+
+/**
+ * The context window to apply for `modelId` on `host`: the discovered per-model figure when
+ * discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
+ * (no override applied, never a guess). ONE helper for every read site: the swap route,
+ * the quickstart route and boot recovery each re-write the CLI's config files, and a site
+ * that forgot the fallback silently loses the endpoint default on the next respawn.
+ */
+export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined {
+ return host.modelContextLengths?.[modelId] ?? host.defaultContextLength;
}
export function customModelHostsPath(configDir: string): string {
diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts
index 43cfd5ad..aa9deaf4 100644
--- a/src/custom-model-injection-apply.ts
+++ b/src/custom-model-injection-apply.ts
@@ -10,9 +10,9 @@
* cli-registry changes" requirement it was written against.
*/
-import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
+import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { homedir, platform } from 'node:os';
-import { join, dirname } from 'node:path';
+import { join, dirname, isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
@@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string {
* Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full
* envOverrides object a caller should merge into the session/process env
* (the dir-redirect var plus any `extraEnv` the config file references by
- * name). Never touches anything outside `baseDir` — the caller is
- * responsible for choosing an isolated directory (never the user's real
- * `~/.codex`, `~/.pi`, etc.).
+ * name). The generated files stay under `baseDir`, but a recipe that declares
+ * `linkFromRealHome` additionally links (and, for missing directory targets,
+ * creates) entries in the CLI's REAL home: the caller is still responsible
+ * for choosing an isolated `baseDir` (never the user's real `~/.codex`,
+ * `~/.pi`, etc.), it is just no longer the only directory touched.
*
* pi and omp embed the API key literally in the file, so the tree is written
* 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod
* covers a re-apply onto a file that already exists (`mode` only applies at
* creation).
*/
-export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record {
+export function applyConfigDirInjection(
+ baseDir: string,
+ injection: ConfigDirInjection,
+ /**
+ * The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
+ * set one: a user who relocated the CLI's real home (an allowlisted key like
+ * DSH_HOME) must have their state linked from THAT directory, not the server's.
+ */
+ sessionHomeOverride?: string
+): Record {
for (const file of injection.files) {
const filePath = join(baseDir, file.relPath);
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
}
+ linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
}
+/**
+ * Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
+ * into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
+ * generated config file and not the state that has to survive the session (deepseek:
+ * the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
+ * bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
+ * this consumer is recipe-driven and names no CLI itself.
+ *
+ * Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
+ * onto a session already holding a previous injection's value must not self-link), then
+ * the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
+ * are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
+ * cwd, and joining it here would mkdir/symlink under the SERVER's working directory and
+ * leave relative links that dangle. No home resolvable =
+ * no links, same best-effort posture as `linkSharedProjectsDir()`.
+ */
+function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void {
+ const names = injection.linkFromRealHome;
+ if (!names || names.length === 0) return;
+ const absoluteOnly = (value: string | undefined): string | undefined =>
+ value && isAbsolute(value) ? value : undefined;
+ const fromSession = sessionHomeOverride?.trim();
+ const realHome =
+ absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) ||
+ absoluteOnly(process.env[injection.dirEnvVar]?.trim()) ||
+ (injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined);
+ if (!realHome) return;
+ const dirs = new Set(injection.linkFromRealHomeDirs ?? []);
+ for (const name of names) {
+ linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
+ }
+}
+
+function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
+ // lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
+ // re-applies on every restart, so the second pass would try to recreate the link and
+ // throw EEXIST. lstat answers for real entries, good links and dangling links alike.
+ try {
+ if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
+ } catch {
+ // ENOENT: nothing is here yet, the only state in which a link gets created
+ }
+ if (!existsSync(target)) {
+ if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
+ // Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
+ // recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
+ // `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
+ // what dsh itself does on a first turn.
+ try {
+ mkdirSync(target, { recursive: true, mode: 0o700 });
+ } catch {
+ return; // e.g. the target's parent refuses writes: no link rather than a dangling one
+ }
+ }
+ try {
+ symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
+ } catch {
+ // best-effort only: a platform that refuses symlinks keeps the documented
+ // side effect for this session rather than failing the whole apply over it
+ }
+}
+
/**
* Real, shared Claude config directory Codeman's own host process runs under — honors
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
@@ -250,7 +324,13 @@ export function applyCustomModelInjection(
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
*/
- workingDir?: string
+ workingDir?: string,
+ /**
+ * Reads one env var from the SESSION's own env overrides (single-key on purpose: the
+ * map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
+ * home for `linkFromRealHome` from the session's value before the server's.
+ */
+ sessionEnvLookup?: (envVar: string) => string | undefined
): AppliedCustomModel | undefined {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -283,6 +363,6 @@ export function applyCustomModelInjection(
};
}
const configDir = customModelConfigDir(sessionId);
- const envOverrides = applyConfigDirInjection(configDir, injection);
+ const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
}
diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts
index 9aab9422..5bee035c 100644
--- a/src/custom-model-injection.ts
+++ b/src/custom-model-injection.ts
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
* `customModelInjection.launchModel` template, never hand-built per CLI.
*/
launchModel?: string;
+ /** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
+ linkFromRealHome?: string[];
+ /** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
+ linkFromRealHomeDirs?: string[];
+ /** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
+ realHomeFallback?: string;
}
export interface UnsupportedInjection {
@@ -136,14 +142,22 @@ export function buildCustomModelInjection(
}
case 'configContentEnv': {
- const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
+ const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
}
case 'configDir': {
- const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
+ const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
return withLaunchModel(
- { kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
+ {
+ kind: 'configDir',
+ dirEnvVar: cap.dirEnvVar,
+ files: [{ relPath: cap.fileName, content }],
+ extraEnv,
+ ...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
+ ...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
+ ...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
+ },
cap.launchModel,
modelId
);
@@ -164,41 +178,114 @@ function withLaunchModel(
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
}
+/**
+ * One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
+ * model's window must not lose a disproportionate slice to the reservation). Returns undefined
+ * for an unknown or non-finite size: opencode then keeps its own default guess, which is the
+ * pre-fix behaviour; never invent a limit here.
+ */
+function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
+ if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
+ const total = Math.trunc(contextLength);
+ if (total <= 0) return undefined;
+ const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
+ // `limit.context` is the FULL window as far as opencode is concerned: it reserves for
+ // the reply itself, so the discovered total is passed through, never a shrunk figure.
+ return { context: total, output };
+}
+
function renderConfigContent(
template: 'opencode-json',
endpoint: CustomModelEndpoint,
modelId: string,
- apiKey: string
+ apiKey: string,
+ contextLength?: number
): string {
switch (template) {
- case 'opencode-json':
+ case 'opencode-json': {
+ // `limit` carries the discovered context window (issue #749): without it opencode assumes
+ // a large default window for an unrecognized model id and eventually overflows a much
+ // smaller local server. `context` is the FULL window and opencode reserves for the reply
+ // inside it itself, so `opencodeModelLimit` passes the real total through and only
+ // `output` gets the capped reservation.
+ const limit = opencodeModelLimit(contextLength);
return JSON.stringify({
$schema: 'https://opencode.ai/config.json',
provider: {
custom: {
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
- models: { [modelId]: {} },
+ models: { [modelId]: limit ? { limit } : {} },
},
},
model: `custom/${modelId}`,
});
+ }
}
}
-const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
+/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
+ * reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
+ * name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
+ * so an in-session `/model` switch back to `deepseek-official` (or a profile with web
+ * search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
+ * entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
+ */
+const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
/** The `[model.]` block name grok's config.toml uses for the injected model — also
* what `-m ` in the standalone script's ONE_SHOT argv must reference to select it. */
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
function renderConfigFile(
- template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
+ template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
endpoint: CustomModelEndpoint,
modelId: string,
- apiKey: string
+ apiKey: string,
+ contextLength?: number
): { content: string; extraEnv?: Record } {
const baseUrl = withV1Suffix(endpoint.baseUrl);
switch (template) {
+ case 'deepseek-patch-json': {
+ const route = { provider: 'codeman-custom', model: modelId };
+ return {
+ content: JSON.stringify(
+ [
+ {
+ id: 'llm-pi-ai',
+ name: '@deepseek-ai/dsh-llm-pi-ai',
+ config: {
+ providers: {
+ 'codeman-custom': {
+ // By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
+ apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
+ api: 'openai-completions',
+ baseURL: baseUrl,
+ models: [
+ {
+ id: modelId,
+ name: modelId,
+ ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
+ ? {
+ contextWindow: Math.trunc(contextLength),
+ // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
+ maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
+ }
+ : {}),
+ },
+ ],
+ },
+ },
+ },
+ },
+ { id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
+ { id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
+ ],
+ null,
+ 2
+ ),
+ extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
+ };
+ }
case 'codex-toml': {
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
// a `[model].default` table — codex rejects that with "invalid type: map, expected
@@ -217,15 +304,21 @@ function renderConfigFile(
const content = [
`model = ${quoted(modelId)}`,
`model_provider = "custom"`,
+ ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
+ ? [
+ `model_context_window = ${Math.trunc(contextLength)}`,
+ `model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
+ ]
+ : []),
'',
'[model_providers.custom]',
`name = "Custom Endpoint"`,
`base_url = ${quoted(baseUrl)}`,
- `env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
+ `env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
`wire_api = "responses"`,
'',
].join('\n');
- return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
+ return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
}
case 'pi-models-json':
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts
new file mode 100644
index 00000000..a8703371
--- /dev/null
+++ b/src/discord-bridge.ts
@@ -0,0 +1,1334 @@
+/**
+ * @fileoverview Two-way Discord bridge: one thread per tab, alerts out, replies typed back in.
+ *
+ * Split like webhook-notify.ts, so the parts that matter are testable without a network:
+ * - pure: `sanitiseReply`, `threadNameFor`, `buildPost`, `discordConfigProblem`, `maskToken`
+ * - store: `~/.codeman/discord.json` (bot token, 0600 via tmp+rename) and
+ * `~/.codeman/discord-bridge.json` (sessionId -> threadId, not secret)
+ * - IO: `DiscordApi` (REST, injected fetch), `DiscordGateway` (inbound WebSocket, injected
+ * socket factory) and `DiscordBridge` (the rules that tie them to sessions)
+ *
+ * Rules the code keeps and the tests pin:
+ * - The bot token lives only in `discord.json`; it is never returned, logged, or put in an error.
+ * - Only messages from `allowedUserIds`, in `guildId`, inside a thread this bridge created, are
+ * acted on. Everything else is dropped without being read further.
+ * - A reply becomes one printable line plus Enter. Embedded newlines would submit early. A lone digit
+ * while a permission or question is pending goes as the key alone (see onGatewayMessage).
+ * - Posts never ping unless `pingMode` says so, and then only the allowlisted user ids.
+ * - A Discord outage or a bad token never reaches a session: every IO path catches and backs off.
+ *
+ * @module discord-bridge
+ */
+
+import { existsSync, mkdirSync } from 'node:fs';
+import fs from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import { join } from 'node:path';
+import { probePathKind } from './utils/bounded-path-probe.js';
+import { DISCORD_RECEIVED_DIR } from './web/paste-image-gc.js';
+import WebSocket from 'ws';
+import { WEBHOOK_SCOPES, type WebhookUrgency } from './types/push.js';
+import {
+ DISCORD_ON_DELETE,
+ DISCORD_PING_MODES,
+ DISCORD_THREAD_CREATION,
+ type DiscordConfig,
+ type DiscordGatewayState,
+ type DiscordPingMode,
+ type DiscordResult,
+} from './types/discord.js';
+
+const CONFIG_FILE = 'discord.json';
+const THREADS_FILE = 'discord-bridge.json';
+const API = 'https://discord.com/api/v10';
+const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
+const SEND_TIMEOUT_MS = 8000;
+const MAX_CONTENT = 1900;
+const MAX_THREAD_NAME = 100;
+/** Longest reply typed into a session; Discord allows 2000, a CLI prompt rarely needs more than this. */
+export const MAX_REPLY_CHARS = 1000;
+const REPLY_MIN_INTERVAL_MS = 1000;
+const DEDUPE_WINDOW_MS = 3000;
+/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, switched on in the Developer Portal). */
+const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
+/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */
+const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
+const SNOWFLAKE = /^\d{15,25}$/;
+/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */
+const STALE_MIN_AGE_MS = 60 * 60 * 1000;
+const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
+/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */
+const STALE_MAX_PER_SWEEP = 25;
+const DISCORD_EPOCH_MS = 1420070400000;
+/** A Markdown table wider or longer than this is posted as it was written, not padded into a code block. */
+const TABLE_MAX_COLS = 24;
+const TABLE_MAX_ROWS = 200;
+/**
+ * The most of a last message worth converting: what 8 posts can carry, twice over, since a table can
+ * shrink when formatted. The rest could never be posted, and a padded table costs its widest cell
+ * times its rows, so an agent-steerable message must not reach the conversion whole.
+ */
+const MAX_CONVERTED_TEXT = MAX_CONTENT * 8 * 2;
+/** Reading a CLI's transcript must not hold an alert back for long. */
+const FULL_TEXT_TIMEOUT_MS = 4000;
+
+export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
+ enabled: false,
+ token: '',
+ guildId: '',
+ parentChannelId: '',
+ allowedUserIds: [],
+ threadCreation: 'lazy',
+ onSessionDelete: 'archive',
+ staleThreads: 'keep',
+ pingMode: 'critical',
+ scope: 'attention',
+ startupHint: true,
+};
+
+export interface DiscordMessage {
+ event: string;
+ title: string;
+ body: string;
+ urgency: WebhookUrgency;
+ sessionId?: string;
+ sessionName?: string;
+ /** The tail of what the CLI last printed, so the reader can see what it is asking. */
+ tail?: string | (() => string);
+ /**
+ * The CLI's whole last message, read from its transcript. When it yields text the post carries that
+ * (split over as many posts as it needs, markdown kept) instead of the screen tail.
+ */
+ fullText?: () => Promise<{ text: string; version?: string } | undefined>;
+ host?: string;
+ /** A file to upload with the post (already read and size-checked by the caller). */
+ file?: DiscordFile;
+}
+
+export interface DiscordFile {
+ name: string;
+ data: Uint8Array;
+}
+
+// ---------------------------------------------------------------------------
+// Pure
+// ---------------------------------------------------------------------------
+
+/** Why `cfg` cannot be saved as an enabled config, or null. */
+export function discordConfigProblem(cfg: DiscordConfig): string | null {
+ if (cfg.token && (/\s/.test(cfg.token) || cfg.token.length > 200)) return 'The bot token looks wrong';
+ if (cfg.guildId && !SNOWFLAKE.test(cfg.guildId)) return 'Guild ID must be a number (Copy ID in Discord)';
+ if (cfg.parentChannelId && !SNOWFLAKE.test(cfg.parentChannelId)) return 'Channel ID must be a number';
+ if (cfg.allowedUserIds.some((id) => !SNOWFLAKE.test(id))) return 'Each allowed user ID must be a number';
+ if (cfg.allowedUserIds.length > 20) return 'At most 20 allowed user IDs';
+ if (cfg.enabled) {
+ if (!cfg.token) return 'Add the bot token before enabling';
+ if (!cfg.guildId || !cfg.parentChannelId) return 'Add the guild ID and parent channel ID before enabling';
+ if (cfg.allowedUserIds.length === 0) return 'Add at least one allowed user ID: nobody could reply otherwise';
+ }
+ return null;
+}
+
+/** First four and last two characters, so the user can tell which token is saved. */
+export function maskToken(token: string): string {
+ return token.length > 12 ? `${token.slice(0, 4)}•••${token.slice(-2)}` : token ? '•••' : '';
+}
+
+const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
+
+/**
+ * A Discord reply as one printable line plus Enter, or null when nothing printable is left. The caller
+ * drops the Enter for a lone digit answering a pending dialog.
+ */
+export function sanitiseReply(raw: string, files: string[] = []): string | null {
+ // eslint-disable-next-line no-control-regex
+ const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
+ const trimmed = clip(line.trim(), MAX_REPLY_CHARS);
+ // Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut.
+ const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : '';
+ // eslint-disable-next-line no-control-regex
+ const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
+ return out ? `${out}\r` : null;
+}
+
+export function threadNameFor(sessionName: string | undefined, sessionId: string): string {
+ const name = (sessionName ?? '').replace(/[\r\n]+/g, ' ').trim();
+ return clip(name || sessionId.slice(0, 8), MAX_THREAD_NAME);
+}
+
+/** Whether `urgency` earns a ping under `mode`. */
+export function shouldPing(mode: DiscordPingMode, urgency: WebhookUrgency, explicitUrgent = false): boolean {
+ if (mode === 'off') return false;
+ return mode === 'all' || explicitUrgent || urgency === 'critical';
+}
+
+export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency): boolean {
+ if (!cfg.enabled || !cfg.token) return false;
+ return cfg.scope === 'all' || urgency !== 'info';
+}
+
+/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */
+export const MAX_FILE_BYTES = 8 * 1024 * 1024;
+const MAX_FILES_PER_REPLY = 5;
+const DOWNLOAD_TIMEOUT_MS = 30_000;
+/** Where a session's received files go, relative to its working folder. */
+export const RECEIVED_DIR = DISCORD_RECEIVED_DIR;
+
+/**
+ * What a session is told about the bridge. Claude gets it as `--append-system-prompt`; no other CLI
+ * is told. No apostrophes: it is rendered into a shell command.
+ */
+export const DISCORD_STARTUP_HINT =
+ 'Codeman note: if you are blocked on something only the user can do (a physical step such as ' +
+ 'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' +
+ 'codeman notify "message that leads with the action" --urgent (lead with the action). The user reads a Discord thread for ' +
+ 'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' +
+ 'given in that line. Add --file to send a file from your working folder. Not for routine progress.';
+
+let activeStartupHint: string | undefined;
+/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */
+export function getDiscordStartupHint(): string | undefined {
+ return activeStartupHint;
+}
+
+/**
+ * The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and
+ * pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line.
+ * With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status
+ * and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option
+ * (`❯ 1. Yes`), so a question's choices stay.
+ */
+export function screenTail(screen: string, maxLines = 14, maxChars = 900, composerGlyph?: string): string {
+ let lines = screen
+ // eslint-disable-next-line no-control-regex
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '')
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
+ .split(/\r?\n/)
+ .map((l) => l.trimEnd())
+ .filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l));
+ if (composerGlyph) {
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) {
+ const row = lines[i].trimStart();
+ if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) {
+ lines = lines.slice(0, i);
+ break;
+ }
+ }
+ }
+ let out = lines.slice(-maxLines);
+ while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1);
+ return out.join('\n').slice(-maxChars);
+}
+
+/**
+ * Discord does not render Markdown tables: a `| a | b |` block shows as raw pipes. Each table outside a
+ * code fence becomes a code block with the columns padded to their widest cell (right-aligned where the
+ * separator row says `---:`), which a monospace font lines up. Bold and code marks inside a cell are
+ * dropped, since a code block would show them literally.
+ */
+export function tablesToCodeBlocks(text: string): string {
+ const cells = (row: string): string[] => {
+ let r = row.trim();
+ if (r.startsWith('|')) r = r.slice(1);
+ if (r.endsWith('|') && !r.endsWith('\\|')) r = r.slice(0, -1);
+ return r.split(/(?
+ c
+ .replace(/\\\|/g, '|')
+ .replace(/\*\*|`/g, '')
+ .trim()
+ );
+ };
+ // Whitespace stripped first, so the pattern has none to backtrack over (a run of spaces made the old one quadratic).
+ const isSeparator = (row: string) => {
+ const r = row.replace(/\s+/g, '');
+ return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r);
+ };
+ const lines = text.split('\n');
+ const out: string[] = [];
+ let fence = false;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (/^\s*```/.test(line)) fence = !fence;
+ if (fence || !line.includes('|') || i + 1 >= lines.length || !isSeparator(lines[i + 1])) {
+ out.push(line);
+ continue;
+ }
+ // Rows belong to the table while they carry a pipe, and start with one when the header does, so a
+ // sentence with a pipe under a table stays prose.
+ const piped = line.trim().startsWith('|');
+ let j = i + 2;
+ while (
+ j < lines.length &&
+ lines[j].trim() !== '' &&
+ lines[j].includes('|') &&
+ (!piped || lines[j].trim().startsWith('|'))
+ ) {
+ j++;
+ }
+ const header = cells(line);
+ // A table past these bounds is left as it was (raw pipes): formatting costs columns times rows, and the
+ // text can be steered by whatever the agent read.
+ if (header.length > TABLE_MAX_COLS || j - i - 1 > TABLE_MAX_ROWS) {
+ for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack
+ i = j - 1;
+ continue;
+ }
+ const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left'));
+ const rows = [header];
+ for (let k = i + 2; k < j; k++) rows.push(cells(lines[k]));
+ let cols = 0;
+ for (const r of rows) if (r.length > cols) cols = r.length;
+ if (cols > TABLE_MAX_COLS) {
+ for (let k = i; k < j; k++) out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack
+ i = j - 1;
+ continue;
+ }
+ const width = Array.from({ length: cols }, () => 1);
+ for (const r of rows) for (let c = 0; c < r.length; c++) width[c] = Math.max(width[c], [...r[c]].length);
+ const pad = (v: string, c: number) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c]));
+ const fmt = (r: string[]) =>
+ Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c))
+ .join(' ')
+ .trimEnd();
+ out.push('```', fmt(header), width.map((w) => '-'.repeat(w)).join(' '), ...rows.slice(1).map(fmt), '```');
+ i = j - 1;
+ }
+ return out.join('\n');
+}
+
+/**
+ * Split a long message into posts of at most `max` characters, on blank lines first, then lines, then
+ * (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post
+ * renders on its own. At most `maxPosts`; the last carries a note when text is left over.
+ */
+export function splitForDiscord(text: string, max = 1900, maxPosts = 8): string[] {
+ const posts: string[] = [];
+ let cur = '';
+ let fence = false;
+ let cut = false;
+ const flush = () => {
+ if (cur.trim()) posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd());
+ cur = fence ? '```\n' : '';
+ };
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
+ for (let line of lines) {
+ if (posts.length >= maxPosts) {
+ cut = true;
+ break;
+ }
+ while (line.length > max - 8) {
+ if (cur.trim()) flush();
+ cur += line.slice(0, max - 8) + '\n';
+ line = line.slice(max - 8);
+ flush();
+ if (posts.length >= maxPosts) {
+ cut = true;
+ break;
+ }
+ }
+ if (cut) break;
+ if (cur.length + line.length + 1 > max - 8) flush();
+ cur += `${line}\n`;
+ if (/^\s*```/.test(line)) fence = !fence;
+ }
+ if (!cut && cur.trim()) posts.push(cur.trimEnd());
+ if (cut && posts.length > 0) posts[posts.length - 1] += '\n*(The message continues in the tab.)*';
+ return posts.slice(0, maxPosts);
+}
+
+/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */
+export function safeAttachmentName(name: string): string {
+ const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '');
+ return clip(base, 80) || 'file';
+}
+
+/** Discord's own CDN over https: the only place an attachment URL may point. */
+export function isDiscordCdnUrl(raw: string): boolean {
+ try {
+ const u = new URL(raw);
+ return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname);
+ } catch {
+ return false;
+ }
+}
+
+export interface DiscordPost {
+ content: string;
+ allowed_mentions: { parse: string[]; users: string[] };
+}
+
+export function buildPost(msg: DiscordMessage, cfg: DiscordConfig, explicitUrgent = false): DiscordPost {
+ const ping = shouldPing(cfg.pingMode, msg.urgency, explicitUrgent) ? cfg.allowedUserIds : [];
+ const mentions = ping.map((id) => `<@${id}>`).join(' ');
+ const head = `${mentions ? `${mentions} ` : ''}**${clip(msg.title, 120)}**`;
+ // Built here, not by the caller, so the screen is only read for a post that is really going out.
+ let tailText: string | undefined;
+ try {
+ tailText = typeof msg.tail === 'function' ? msg.tail() : msg.tail;
+ } catch {
+ tailText = undefined;
+ }
+ const fence = tailText?.trim() ? `\n\`\`\`\n${tailText.trim().replace(/```/g, "'''")}\n\`\`\`` : '';
+ const host = msg.host ? ` (${clip(msg.host, 60)})` : '';
+ const content = clip(`${head}${host}${msg.body ? `\n${msg.body}` : ''}${fence}`, MAX_CONTENT);
+ // Agent output and session names must not be able to @everyone the server; only the allowlisted ids ping.
+ return { content, allowed_mentions: { parse: [], users: ping } };
+}
+
+// ---------------------------------------------------------------------------
+// Store
+// ---------------------------------------------------------------------------
+
+export function discordConfigPath(configDir: string): string {
+ return join(configDir, CONFIG_FILE);
+}
+
+const oneOf = (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),
+ 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,
+ };
+}
+
+function withTimeout(p: Promise, ms: number): Promise {
+ let timer: ReturnType;
+ return Promise.race([
+ p,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('timed out')), ms);
+ timer.unref?.();
+ }),
+ ]).finally(() => clearTimeout(timer));
+}
+
+/** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */
+class BridgeProblem extends Error {}
+
+export async function readDiscordConfig(configDir: string): Promise {
+ 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 {
+ // A unique name: two writes at once (two admin tabs) must not share one tmp file.
+ const tmp = `${target}.${randomUUID()}.tmp`;
+ try {
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode });
+ await fs.rename(tmp, target);
+ } catch (err) {
+ await fs.unlink(tmp).catch(() => undefined);
+ throw err;
+ }
+}
+
+/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
+export async function writeDiscordConfig(configDir: string, cfg: DiscordConfig): Promise {
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
+ await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600);
+}
+
+/** The thread map plus the parent channel it was built under. */
+async function readThreadFile(configDir: string): Promise<{ parent: string; threads: Record }> {
+ try {
+ const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8')) as Record;
+ const threads: Record = {};
+ const saved = typeof raw.threads === 'object' && raw.threads !== null ? raw.threads : {};
+ for (const [k, v] of Object.entries(saved)) if (typeof v === 'string' && SNOWFLAKE.test(v)) threads[k] = v;
+ return { parent: typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '', threads };
+ } catch {
+ return { parent: '', threads: {} };
+ }
+}
+
+export async function readThreadMap(configDir: string): Promise> {
+ return (await readThreadFile(configDir)).threads;
+}
+
+async function writeThreadMap(
+ configDir: string,
+ parentChannelId: string,
+ threads: Record
+): Promise {
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
+ await writePrivate(join(configDir, THREADS_FILE), { parentChannelId, threads }, 0o600);
+}
+
+// ---------------------------------------------------------------------------
+// IO: REST
+// ---------------------------------------------------------------------------
+
+export interface DiscordThreadInfo {
+ id: string;
+ parent_id?: string;
+ owner_id?: string;
+ thread_metadata?: { archived?: boolean };
+}
+
+export type DiscordFetch = (target: URL, init: RequestInit) => Promise;
+
+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 isForm = typeof FormData !== 'undefined' && body instanceof FormData;
+ const res = await this.fetchImpl(new URL(`${API}${path}`), {
+ method,
+ headers: {
+ Authorization: `Bot ${this.token()}`,
+ // A form body sets its own multipart Content-Type (with the boundary).
+ ...(isForm ? {} : { 'Content-Type': 'application/json' }),
+ 'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)',
+ },
+ body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body),
+ redirect: 'manual',
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
+ });
+ if (res.status === 429 && !retried) {
+ const j = (await res.json().catch(() => ({}))) as { retry_after?: number };
+ await this.sleep(Math.min(Math.max((j.retry_after ?? 1) * 1000, 100), 10_000));
+ return this.request(method, path, body, true);
+ }
+ if (!res.ok) {
+ const j = (await res.json().catch(() => ({}))) as { code?: number };
+ throw new DiscordHttpError(res.status, j.code);
+ }
+ if (res.status === 204) return null;
+ return (await res.json().catch(() => null)) as T | null;
+ }
+
+ createThread(parentId: string, name: string): Promise<{ id: string } | null> {
+ // type 11 = public thread. A week of inactivity before Discord archives it.
+ return this.request('POST', `/channels/${parentId}/threads`, { name, type: 11, auto_archive_duration: 10080 });
+ }
+ post(channelId: string, p: DiscordPost): Promise<{ id: string } | null> {
+ return this.request('POST', `/channels/${channelId}/messages`, p);
+ }
+ postWithFile(channelId: string, p: DiscordPost, file: DiscordFile): Promise<{ id: string } | null> {
+ const form = new FormData();
+ form.append('payload_json', JSON.stringify(p));
+ form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name));
+ return this.request('POST', `/channels/${channelId}/messages`, form);
+ }
+ rename(threadId: string, name: string): Promise {
+ 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}`);
+ }
+ 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 {
+ return this.request(
+ 'PUT',
+ `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// IO: gateway
+// ---------------------------------------------------------------------------
+
+export interface GatewayMessage {
+ id: string;
+ channel_id: string;
+ guild_id?: string;
+ content: string;
+ author: { id: string; bot?: boolean };
+ attachments?: Array<{ id: string; filename: string; size: number; url: string }>;
+}
+
+/** The subset of `ws` the gateway uses, so tests can drive it without a socket. */
+export interface GatewaySocket {
+ send(data: string): void;
+ close(code?: number): void;
+ on(event: 'message', cb: (data: { toString(): string }) => void): void;
+ on(event: 'close', cb: (code: number) => void): void;
+ on(event: 'error', cb: (err: Error) => void): void;
+}
+export type SocketFactory = (url: string) => GatewaySocket;
+
+const defaultSocket: SocketFactory = (url) => new WebSocket(url) as unknown as GatewaySocket;
+
+/**
+ * Inbound half: identify, heartbeat, resume and reconnect with backoff. Only MESSAGE_CREATE is
+ * surfaced. A close code that retrying cannot fix (bad token, intent not enabled) stops it and
+ * records `lastError` for the Settings status line.
+ */
+export class DiscordGateway {
+ state: DiscordGatewayState = 'off';
+ lastError: string | null = null;
+ private sock: GatewaySocket | null = null;
+ private seq: number | null = null;
+ private sessionId: string | null = null;
+ private resumeUrl: string | null = null;
+ private hb: ReturnType | 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 {
+ // `ws` keeps emitting while a socket closes: a frame after stop() or from a replaced socket must start nothing.
+ if (this.stopped || sock !== this.sock) return;
+ let p: { op: number; d?: unknown; s?: number | null; t?: string | null };
+ try {
+ p = JSON.parse(raw);
+ } catch {
+ return;
+ }
+ if (typeof p.s === 'number') this.seq = p.s;
+ switch (p.op) {
+ case 10: {
+ const interval = Number((p.d as { heartbeat_interval?: number })?.heartbeat_interval) || 41_250;
+ this.acked = true;
+ this.hb = setInterval(() => {
+ if (!this.acked) {
+ // Zombie connection: no ack since the last beat. Resume on a fresh socket.
+ try {
+ sock.close(4000);
+ } catch {
+ /* already closed */
+ }
+ return;
+ }
+ this.acked = false;
+ sock.send(JSON.stringify({ op: 1, d: this.seq }));
+ }, interval);
+ if (this.sessionId && this.seq !== null) {
+ sock.send(JSON.stringify({ op: 6, d: { token: this.token(), session_id: this.sessionId, seq: this.seq } }));
+ } else {
+ sock.send(
+ JSON.stringify({
+ op: 2,
+ d: {
+ token: this.token(),
+ intents: GATEWAY_INTENTS,
+ properties: { os: 'linux', browser: 'codeman', device: 'codeman' },
+ },
+ })
+ );
+ }
+ break;
+ }
+ case 11:
+ this.acked = true;
+ break;
+ case 1:
+ sock.send(JSON.stringify({ op: 1, d: this.seq }));
+ break;
+ case 7: // server asked us to reconnect (resumable)
+ sock.close(4000);
+ break;
+ case 9: // invalid session: resumable only if d === true
+ if (p.d !== true) {
+ this.sessionId = null;
+ this.seq = null;
+ this.resumeUrl = null;
+ }
+ sock.close(4000);
+ break;
+ case 0:
+ if (p.t === 'READY') {
+ const d = p.d as { session_id?: string; resume_gateway_url?: string };
+ this.sessionId = d.session_id ?? null;
+ this.resumeUrl = d.resume_gateway_url ? `${d.resume_gateway_url}/?v=10&encoding=json` : null;
+ this.state = 'connected';
+ this.lastError = null;
+ this.attempts = 0;
+ } else if (p.t === 'RESUMED') {
+ this.state = 'connected';
+ this.attempts = 0;
+ } else if (p.t === 'MESSAGE_CREATE') {
+ this.onMessage(p.d as GatewayMessage);
+ }
+ break;
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// The bridge
+// ---------------------------------------------------------------------------
+
+/**
+ * Create or re-verify `/.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 {
+ 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 a person's Discord reply into the session (a line ending in Enter, or a lone digit for a
+ * dialog). False when the session is gone.
+ */
+ sendInput: (sessionId: string, text: string) => Promise;
+ /** True once the tab's agent has exited: tmux still accepts keys for a dead pane, so a tick would lie. */
+ agentExited?: (sessionId: string) => boolean;
+ /** True while the session shows a permission or question dialog, which acts on a bare keypress. */
+ awaitingChoice?: (sessionId: string) => boolean;
+ /** A session's working folder, where received files are saved. Undefined when it is gone or remote. */
+ sessionDir?: (sessionId: string) => string | undefined;
+ socketFactory?: SocketFactory;
+ now?: () => number;
+ sleep?: (ms: number) => Promise;
+ backoffMs?: (attempt: number) => number;
+}
+
+export class DiscordBridge {
+ private cfg: DiscordConfig = { ...DEFAULT_DISCORD_CONFIG };
+ private threads: Record = {};
+ private loaded = false;
+ private gateway: DiscordGateway | null = null;
+ /** The token the running gateway was started with ('' when none). */
+ private gatewayToken = '';
+ 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>();
+ /** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */
+ private closedWhilePending = new Set();
+ /** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */
+ private lastFull = new Map();
+ /** 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 = Promise.resolve();
+ /** Per-thread chain of inbound messages (see onGatewayMessage). */
+ private inbound = new Map>();
+ private mapLoad?: Promise;
+ private sweepTimer?: ReturnType;
+ /** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
+ private firstLoad?: Promise;
+ private last: DiscordResult | null = null;
+ private readonly api: DiscordApi;
+ private readonly now: () => number;
+
+ constructor(private readonly deps: DiscordBridgeDeps) {
+ this.now = deps.now ?? Date.now;
+ this.api = new DiscordApi(() => this.cfg.token, deps.fetchImpl, deps.sleep);
+ }
+
+ get lastResult(): DiscordResult | null {
+ return this.last;
+ }
+ get gatewayState(): DiscordGatewayState {
+ return this.gateway?.state ?? 'off';
+ }
+ get gatewayError(): string | null {
+ return this.gateway?.lastError ?? null;
+ }
+
+ async ensureLoaded(): Promise {
+ if (this.loaded) return;
+ this.firstLoad ??= this.reload();
+ await this.firstLoad;
+ }
+
+ /** Forget thread entries for tabs that no longer exist (dropped without a delete, for example). Once, at boot. */
+ async prune(liveIds: Iterable): Promise {
+ await this.ensureLoaded();
+ const live = new Set(liveIds);
+ if (live.size === 0) return; // nothing restored: more likely a failed restore than an empty server
+ const stale = Object.keys(this.threads).filter((id) => !live.has(id));
+ if (stale.length === 0) return;
+ for (const id of stale) delete this.threads[id];
+ await this.persistThreads();
+ }
+
+ /**
+ * Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after
+ * the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one
+ * (its map entry may not be written yet), or anything while the map is empty (a lost map would make
+ * every thread look stale). Returns how many it archived or deleted. Never throws.
+ */
+ async sweepStaleThreads(): Promise {
+ try {
+ await this.ensureLoaded();
+ const c = this.cfg;
+ if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId) return 0;
+ if (Object.keys(this.threads).length === 0 || this.pending.size > 0) return 0;
+ const me = (await this.api.botUserId())?.id;
+ if (!me) return 0;
+ const known = new Set(Object.values(this.threads));
+ const cutoff = this.now() - STALE_MIN_AGE_MS;
+ const stale = (list: DiscordThreadInfo[] | undefined, archived: boolean) =>
+ (list ?? [])
+ .filter(
+ (t) =>
+ t.parent_id === c.parentChannelId &&
+ t.owner_id === me &&
+ SNOWFLAKE.test(t.id) &&
+ !known.has(t.id) &&
+ Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff &&
+ // An archived thread has nothing left to archive.
+ !(archived && c.staleThreads === 'archive')
+ )
+ .map((t) => t.id);
+ const ids = [
+ ...stale((await this.api.activeThreads(c.guildId))?.threads, false),
+ ...(c.staleThreads === 'delete'
+ ? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true)
+ : []),
+ ].slice(0, STALE_MAX_PER_SWEEP);
+ let done = 0;
+ for (const id of ids) {
+ const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then(
+ () => true,
+ () => false
+ );
+ if (ok) done++;
+ }
+ return done;
+ } catch {
+ return 0;
+ }
+ }
+
+ /** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */
+ startStaleSweep(): void {
+ clearInterval(this.sweepTimer);
+ void this.sweepStaleThreads();
+ this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS);
+ this.sweepTimer.unref?.();
+ }
+
+ /** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */
+ hold(): void {
+ this.held = true;
+ }
+
+ /** Boot is done: start the gateway if the bridge is on. */
+ async release(): Promise {
+ if (!this.held) return;
+ this.held = false;
+ await this.reload();
+ }
+
+ /** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */
+ async reload(): Promise {
+ this.cfg = await readDiscordConfig(this.deps.configDir);
+ // The thread map is read once, by whoever gets here first; everyone else waits for that read. A second
+ // read finishing later would replace the map and drop threads made in between.
+ this.mapLoad ??= readThreadFile(this.deps.configDir).then((saved) => {
+ this.threads = { ...saved.threads, ...this.threads };
+ this.threadsParent ||= saved.parent;
+ this.loaded = true;
+ });
+ await this.mapLoad;
+ // Threads belong to the channel they were made under: a new parent channel (or guild) starts afresh,
+ // or every tab would keep posting into the old one.
+ const parent = this.cfg.parentChannelId;
+ if (parent && parent !== this.threadsParent) {
+ this.threads = {};
+ this.pending.clear();
+ this.closedWhilePending.clear();
+ this.threadsParent = parent;
+ await this.persistThreads();
+ }
+ activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined;
+ // A save that changed neither the switch nor the token keeps the gateway session, so it can resume.
+ // A gateway that gave up (a fatal close, such as a missing intent) is replaced, or a fix could never take.
+ const want = this.cfg.enabled && this.cfg.token && !this.held ? this.cfg.token : '';
+ if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken) return;
+ this.gateway?.stop();
+ this.gateway = null;
+ this.gatewayToken = want;
+ if (want) {
+ this.gateway = new DiscordGateway(
+ () => this.cfg.token,
+ (m) => void this.onGatewayMessage(m).catch(() => undefined),
+ this.deps.socketFactory,
+ this.deps.backoffMs
+ );
+ this.gateway.start();
+ }
+ }
+
+ stop(): void {
+ clearInterval(this.sweepTimer);
+ this.sweepTimer = undefined;
+ this.gateway?.stop();
+ this.gateway = null;
+ this.gatewayToken = '';
+ activeStartupHint = undefined;
+ this.lastSent.clear();
+ this.lastReply.clear();
+ this.names.clear();
+ this.lastFull.clear();
+ this.inbound.clear();
+ }
+
+ private fail(err: unknown): DiscordResult {
+ const status = err instanceof DiscordHttpError ? err.status : undefined;
+ const code = err instanceof DiscordHttpError ? err.code : undefined;
+ const error =
+ status === 401
+ ? 'The bot token was refused'
+ : status === 403 || code === 50001
+ ? 'The bot cannot see the parent channel (check its role and the channel ID)'
+ : status === 404
+ ? 'Channel not found (check the IDs)'
+ : status
+ ? `HTTP ${status}`
+ : err instanceof BridgeProblem
+ ? err.message
+ : (err as { name?: string })?.name === 'TimeoutError'
+ ? 'Timed out'
+ : 'Network error';
+ return { ok: false, status, error, at: this.now() };
+ }
+
+ private persistThreads(): Promise {
+ this.persistChain = this.persistChain
+ .then(() => writeThreadMap(this.deps.configDir, this.threadsParent, { ...this.threads }))
+ .catch(() => undefined);
+ return this.persistChain;
+ }
+
+ /** The thread for a session, creating it when absent. Concurrent callers share one creation. */
+ private threadFor(sessionId: string, name: string | undefined): Promise {
+ const existing = this.threads[sessionId];
+ if (existing) return Promise.resolve(existing);
+ const inflight = this.pending.get(sessionId);
+ if (inflight) return inflight;
+ const parent = this.cfg.parentChannelId;
+ const mine: { p?: Promise } = {};
+ const p = (mine.p = (async () => {
+ try {
+ const t = await this.api.createThread(parent, threadNameFor(name, sessionId));
+ if (!t?.id) return this.fail(new BridgeProblem('Discord did not return a thread'));
+ // A save moved the bridge to another channel meanwhile: this thread belongs to the old one.
+ if (parent !== this.cfg.parentChannelId) {
+ await this.closeThread(t.id); // made in the old channel: tidy it
+ return this.fail(new BridgeProblem('The parent channel changed'));
+ }
+ if (this.closedWhilePending.has(sessionId)) {
+ await this.closeThread(t.id);
+ return this.fail(new BridgeProblem('The tab was closed'));
+ }
+ this.threads[sessionId] = t.id;
+ await this.persistThreads();
+ return t.id;
+ } catch (err) {
+ return this.fail(err);
+ } finally {
+ // Only this creation's own entry: a newer one for the same tab may have replaced it.
+ if (this.pending.get(sessionId) === mine.p) {
+ this.pending.delete(sessionId);
+ this.closedWhilePending.delete(sessionId);
+ }
+ }
+ })());
+ this.pending.set(sessionId, p);
+ return p;
+ }
+
+ /** Post an alert. Session-less messages go to the parent channel. Never throws. */
+ async notify(
+ msg: DiscordMessage,
+ opts: { urgent?: boolean; deliberate?: boolean } = {}
+ ): Promise {
+ const explicitUrgent = opts.urgent === true;
+ await this.ensureLoaded();
+ if (!shouldSendDiscord(this.cfg, msg.urgency)) return null;
+ const key = `${msg.event}:${msg.sessionId ?? ''}`;
+ const t = this.now();
+ const prev = this.lastSent.get(key);
+ // A deliberate post (an agent's own `codeman notify`) is never deduped: a second, different message is real.
+ // Automatic events are, and say so, which is not the same answer as "the bridge is off" (null).
+ if (!opts.deliberate && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) {
+ return { ok: true, deduped: true, at: t };
+ }
+ this.lastSent.set(key, t);
+ if (this.lastSent.size > 256) {
+ for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
+ }
+ let chunks: string[] = [];
+ // Remembered only once the first post is out: a failed post must leave the message to be sent again.
+ let posted: string | undefined;
+ if (msg.fullText) {
+ try {
+ const got = await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS);
+ const text = got?.text.trim();
+ const sid = msg.sessionId;
+ if (text && sid) {
+ // Keyed on the text and the turn it came from, so a later turn that ends on the same words is posted.
+ const key = `${got?.version ?? ''}\u0000${text}`;
+ msg = { ...msg, tail: undefined };
+ if (this.lastFull.get(sid) !== key) {
+ chunks = splitForDiscord(tablesToCodeBlocks(text.slice(0, MAX_CONVERTED_TEXT)));
+ posted = key;
+ } // else: said already, so only the header goes
+ }
+ } catch {
+ // no transcript to read: the screen tail stands in
+ }
+ }
+ const post = buildPost(msg, this.cfg, explicitUrgent);
+ // One post when the head and the first part fit together, the rest follow without a ping.
+ if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) {
+ post.content += `\n${chunks.shift()}`;
+ }
+ // Claimed just before the first post and given back if it fails, so an alert for the same tab that
+ // overlaps this one does not post the message too, and a failed post is still sent again.
+ let claimed: { sid: string; before: string | undefined } | undefined;
+ try {
+ let target = this.cfg.parentChannelId;
+ if (msg.sessionId) {
+ const thread = await this.threadFor(msg.sessionId, msg.sessionName);
+ if (typeof thread !== 'string') return (this.last = thread);
+ target = thread;
+ }
+ const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post));
+ if (posted && msg.sessionId) {
+ claimed = { sid: msg.sessionId, before: this.lastFull.get(msg.sessionId) };
+ this.lastFull.set(msg.sessionId, posted);
+ }
+ try {
+ await send(target);
+ } catch (err) {
+ // The thread was deleted by hand: forget it, make a new one, once.
+ if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) {
+ delete this.threads[msg.sessionId];
+ const fresh = await this.threadFor(msg.sessionId, msg.sessionName);
+ if (typeof fresh !== 'string') return (this.last = fresh);
+ await send(fresh);
+ } else {
+ throw err;
+ }
+ }
+ // Parts 2 to 8 are best effort on purpose: the first post, which carries the title, is what is reported.
+ for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part);
+ return (this.last = { ok: true, at: this.now() });
+ } catch (err) {
+ if (claimed) {
+ if (claimed.before === undefined) this.lastFull.delete(claimed.sid);
+ else this.lastFull.set(claimed.sid, claimed.before);
+ }
+ 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 {
+ // Its own client: swapping this.cfg would undo a save that lands while the post is in flight.
+ const api = new DiscordApi(() => cfg.token, this.deps.fetchImpl, this.deps.sleep);
+ try {
+ await api.post(
+ cfg.parentChannelId,
+ buildPost(
+ {
+ event: 'discord:test',
+ title: 'Codeman test notification',
+ body: 'If you can read this, the Discord bridge can post. Replies in session threads are typed into that tab.',
+ urgency: 'info',
+ host,
+ },
+ { ...cfg, pingMode: 'off' }
+ )
+ );
+ return (this.last = { ok: true, at: this.now() });
+ } catch (err) {
+ return (this.last = this.fail(err));
+ }
+ }
+
+ /** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */
+ async sessionCreated(sessionId: string, name: string | undefined): Promise {
+ await this.ensureLoaded();
+ if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return;
+ const made = await this.threadFor(sessionId, name);
+ if (typeof made !== 'string') this.last = made;
+ }
+
+ async sessionRenamed(sessionId: string, name: string | undefined): Promise {
+ const seen = this.names.get(sessionId);
+ this.names.set(sessionId, name ?? '');
+ // First sight only records the name: the thread was made with it, so a PATCH would spend a rename for nothing.
+ if (seen === undefined || seen === (name ?? '')) return;
+ const thread = this.threads[sessionId];
+ if (!thread || !this.cfg.enabled) return;
+ await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined);
+ }
+
+ /**
+ * Drop the per-tab state only, keeping the thread and its map entry. For a session that is discarded
+ * but can come back under the same id (a failed reboot-restore rebuild stays on offer for a retry).
+ * Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server.
+ */
+ forgetTab(sessionId: string): void {
+ this.lastReply.delete(sessionId);
+ this.names.delete(sessionId);
+ this.lastFull.delete(sessionId);
+ }
+
+ /** Session tab deleted: apply `onSessionDelete` and forget the mapping. */
+ async sessionDeleted(sessionId: string): Promise {
+ 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 {
+ if (!this.cfg.enabled) return;
+ if (this.cfg.onSessionDelete === 'delete') await this.api.remove(thread).catch(() => undefined);
+ else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined);
+ }
+
+ private say(channelId: string, content: string): Promise {
+ return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined);
+ }
+
+ /**
+ * Save a reply's attachments under the session's working folder and return the paths. Only Discord
+ * CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is
+ * named in `refused`, so the person is not left guessing why a file did not arrive.
+ */
+ private async saveAttachments(sessionId: string, m: GatewayMessage): Promise<{ paths: string[]; refused?: string }> {
+ const list = m.attachments ?? [];
+ if (list.length === 0) return { paths: [] };
+ const dir = this.deps.sessionDir?.(sessionId);
+ if (!dir) return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' };
+ // The message id goes into a file name, so it must be a snowflake and nothing else.
+ if (!SNOWFLAKE.test(m.id)) return { paths: [], refused: 'That message could not be saved.' };
+ const target = await ensureReceivedDir(dir);
+ if (!target) return { paths: [], refused: 'The tab folder is not safe to write to, so no file was saved.' };
+ const paths: string[] = [];
+ const skipped: string[] = [];
+ for (const [i, a] of list.entries()) {
+ const name = safeAttachmentName(a.filename ?? 'file');
+ if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) {
+ skipped.push(name);
+ continue;
+ }
+ try {
+ const res = await this.deps.fetchImpl(new URL(a.url), {
+ redirect: 'manual',
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
+ });
+ const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
+ if (!buf || buf.byteLength > MAX_FILE_BYTES) {
+ skipped.push(name);
+ continue;
+ }
+ const dest = join(target, `${m.id}-${name}`);
+ // 'wx' (O_EXCL) also refuses a symlink planted at the name.
+ await fs.writeFile(dest, buf, { mode: 0o600, flag: 'wx' });
+ paths.push(dest);
+ } catch {
+ skipped.push(name);
+ }
+ }
+ return {
+ paths,
+ refused: skipped.length
+ ? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}`
+ : undefined,
+ };
+ }
+
+ /** Inbound reply. One chain per thread, so a reply that waits on a file download cannot be overtaken by a later one. */
+ async onGatewayMessage(m: GatewayMessage): Promise {
+ 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 {
+ if (!this.cfg.enabled || m.author?.bot) return;
+ if (m.guild_id !== this.cfg.guildId) return;
+ if (!this.cfg.allowedUserIds.includes(m.author?.id)) return;
+ const sessionId = Object.keys(this.threads).find((k) => this.threads[k] === m.channel_id);
+ if (!sessionId) return;
+ const t = this.now();
+ const prev = this.lastReply.get(sessionId);
+ if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) {
+ // Too soon after the last reply: say it was not typed, instead of dropping it silently.
+ await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined);
+ return;
+ }
+ if (this.deps.agentExited?.(sessionId)) {
+ await this.say(m.channel_id, 'The agent in that tab has exited, so nothing was typed.');
+ return;
+ }
+ this.lastReply.set(sessionId, t);
+ const saved = await this.saveAttachments(sessionId, m);
+ const text = sanitiseReply(m.content ?? '', saved.paths);
+ if (!text) {
+ if (saved.refused) await this.say(m.channel_id, saved.refused);
+ return;
+ }
+ if (saved.refused) await this.say(m.channel_id, saved.refused);
+ // A lone digit answers a dialog by itself: an Enter after it would land on whatever comes next.
+ const keys = /^\d\r$/.test(text) && this.deps.awaitingChoice?.(sessionId) ? text.slice(0, -1) : text;
+ const ok = await this.deps.sendInput(sessionId, keys).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/edit-history-local-source.ts b/src/edit-history-local-source.ts
new file mode 100644
index 00000000..f5c93ea0
--- /dev/null
+++ b/src/edit-history-local-source.ts
@@ -0,0 +1,242 @@
+/**
+ * @fileoverview Reads a local claude session's transcripts for its edit history: the main
+ * transcript and its subagent transcripts, each from a byte offset.
+ *
+ * WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
+ * hooks report, so a session is read from wherever its claude really writes. Before the first
+ * hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
+ * `.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
+ * (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
+ * guessed; it is found with the next hook. Subagents live in
+ * `/subagents/agent-.jsonl`.
+ *
+ * HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
+ * (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
+ * comes along. `budgetBytes` is shared by all files, main first; once spent, files still
+ * report their size with an empty chunk. A request with `len` is a block that must end at
+ * `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
+ * With `onlyRequested` ("older" reads) unrequested files are left out.
+ *
+ * Tests: `test/edit-history-local-source.test.ts`.
+ *
+ * @module edit-history-local-source
+ */
+
+import { open, readdir, realpath, stat } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { basename, isAbsolute, join } from 'node:path';
+import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
+import { claudeProjectsDir } from './utils/claude-transcript.js';
+
+const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
+const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
+const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
+const META_MAX_BYTES = 8192;
+
+/** One file to read from where the last read stopped (`len` only for an "older" block). */
+export interface EditSourceRequest {
+ /** `main` or `subagents/agent-.jsonl`. */
+ relPath: string;
+ offset: number;
+ len?: number;
+}
+
+export interface EditSourceFile {
+ relPath: string;
+ /** The file's size when it was read. */
+ size: number;
+ startOffset: number;
+ chunk: Buffer;
+}
+
+export interface EditSources {
+ /** The conversation id: the main transcript's file name without `.jsonl`. */
+ transcriptId: string;
+ files: EditSourceFile[];
+ /** `agent-.meta.json` text of each subagent read for the first time, by agent id. */
+ metas: Record;
+}
+
+export interface EditSourceReadOptions {
+ /** Read only the requested files; the others are not listed. */
+ onlyRequested?: boolean;
+}
+
+/** What a path turned out to be (for Bash image candidates). */
+export interface PathProbe {
+ /** The path with symlinks resolved. */
+ realPath: string;
+ kind: 'file' | 'directory' | 'other';
+ /** Size in bytes (0 for anything that is not a regular file). */
+ size: number;
+ mtimeMs: number;
+}
+
+async function readRange(path: string, offset: number, len: number): Promise {
+ if (len <= 0) return Buffer.alloc(0);
+ const fh = await open(path, 'r');
+ try {
+ const buf = Buffer.alloc(len);
+ const { bytesRead } = await fh.read(buf, 0, len, offset);
+ return buf.subarray(0, bytesRead);
+ } catch {
+ return Buffer.alloc(0);
+ } finally {
+ await fh.close();
+ }
+}
+
+async function fileSize(path: string): Promise {
+ try {
+ const s = await stat(path);
+ return s.isFile() ? s.size : null;
+ } catch {
+ return null;
+ }
+}
+
+/** The newest `.jsonl` under any project dir, for the first id that has one; null when none does. */
+export async function findClaudeTranscript(
+ ids: readonly string[],
+ projectsDir: string = claudeProjectsDir()
+): Promise {
+ let dirs: string[];
+ try {
+ dirs = await readdir(projectsDir);
+ } catch {
+ return null;
+ }
+ for (const id of new Set(ids)) {
+ if (!ID_PATTERN.test(id)) continue;
+ let best: { path: string; mtime: number } | null = null;
+ for (const dir of dirs) {
+ const path = join(projectsDir, dir, `${id}.jsonl`);
+ try {
+ const s = await stat(path);
+ if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
+ } catch {
+ // not in this project dir
+ }
+ }
+ if (best) return best.path;
+ }
+ return null;
+}
+
+/**
+ * The session's main transcript: the transcript watcher's (hook-reported) path when there is
+ * one, else the newest `.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
+ * override; the server's config dir when unset). Null when neither finds one.
+ */
+export async function resolveClaudeTranscript(
+ watcherPath: string | null | undefined,
+ ids: readonly string[],
+ configDir?: string
+): Promise {
+ return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
+}
+
+/**
+ * Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
+ * is not an absolute `.jsonl` path or the file does not exist.
+ */
+export async function readClaudeEditSources(
+ mainPath: string,
+ requests: readonly EditSourceRequest[],
+ budgetBytes: number,
+ opts: EditSourceReadOptions = {}
+): Promise {
+ if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
+ const transcriptId = basename(mainPath, '.jsonl');
+ if (!ID_PATTERN.test(transcriptId)) return null;
+
+ const byPath = new Map();
+ for (const r of requests) {
+ if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
+ if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
+ if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
+ byPath.set(r.relPath, r);
+ }
+
+ const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
+ const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
+ try {
+ for (const name of (await readdir(subDir)).sort()) {
+ const m = AGENT_FILE_PATTERN.exec(name);
+ if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
+ }
+ } catch {
+ // no subagents yet
+ }
+
+ let remaining = Math.max(0, Math.floor(budgetBytes));
+ const files: EditSourceFile[] = [];
+ const metas: Record = {};
+ for (const src of sources) {
+ const size = await fileSize(src.path);
+ if (size === null) {
+ if (src.relPath === 'main') return null;
+ continue;
+ }
+ const req = byPath.get(src.relPath);
+ if (!req && opts.onlyRequested) continue;
+ let start: number;
+ let len: number;
+ if (req?.len !== undefined) {
+ const blockStart = Math.min(req.offset, size);
+ const blockEnd = Math.min(req.offset + req.len, size);
+ len = Math.min(blockEnd - blockStart, remaining);
+ start = blockEnd - len;
+ } else if (req) {
+ start = Math.min(req.offset, size);
+ len = Math.min(remaining, size - start);
+ } else {
+ len = Math.min(size, remaining);
+ start = size - len;
+ }
+ const chunk = await readRange(src.path, start, len);
+ remaining -= chunk.length;
+ files.push({ relPath: src.relPath, size, startOffset: start, chunk });
+ if (!req && src.agentId) {
+ const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
+ const metaSize = await fileSize(metaPath);
+ if (metaSize !== null) {
+ metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
+ }
+ }
+ }
+ return { transcriptId, files, metas };
+}
+
+/**
+ * Per path its resolved path, kind, size and mtime, or null when it is not a regular file
+ * (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
+ *
+ * The paths come from Bash output, so the agent chose them: each one first goes through the
+ * bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
+ * as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
+ * then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
+ * Sequential, so one sync holds at most one threadpool worker.
+ */
+export async function probeLocalPaths(
+ paths: readonly string[],
+ home: string = homedir(),
+ probeKind: (path: string) => Promise = probePathKind
+): Promise> {
+ const out: Array = [];
+ for (const p of paths) {
+ const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
+ if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
+ out.push(null);
+ continue;
+ }
+ try {
+ const real = await realpath(path);
+ const s = await stat(real);
+ out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
+ } catch {
+ out.push(null);
+ }
+ }
+ return out;
+}
diff --git a/src/edit-history-parse.ts b/src/edit-history-parse.ts
new file mode 100644
index 00000000..409f7dd1
--- /dev/null
+++ b/src/edit-history-parse.ts
@@ -0,0 +1,352 @@
+/**
+ * @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
+ * edited (with their diff) and the images its Bash calls may have produced.
+ *
+ * Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
+ * `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
+ * git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
+ *
+ * Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
+ * yields a CANDIDATE: the image-looking paths in its command and output, with the call's
+ * start and end time. Whether a candidate file was actually produced by the call (its
+ * mtime falls inside that window) is decided later by whoever can stat it, locally or on
+ * the remote host: this module does no IO.
+ *
+ * Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
+ * can land in different chunks. Unanswered calls are handed back as `pending` and passed
+ * into the next call.
+ *
+ * Tests: `test/edit-history-parse.test.ts`.
+ *
+ * @module edit-history-parse
+ */
+
+import { posix } from 'node:path';
+
+/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
+export const MAX_HUNK_BYTES = 64 * 1024;
+/** Image paths kept per Bash call. */
+export const MAX_IMAGE_CANDIDATES = 20;
+/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
+export const MAX_PENDING_BASH = 200;
+/**
+ * Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
+ * this bounds it; an image path past this point of a command is still found in the output.
+ */
+export const MAX_PENDING_COMMAND_CHARS = 8192;
+/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
+export const MAX_PENDING_FIELD_CHARS = 4096;
+
+export type EditKind = 'code' | 'image';
+/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
+export type EditTool = 'Edit' | 'Write' | 'edit';
+
+export interface EditHunk {
+ oldStart: number;
+ oldLines: number;
+ newStart: number;
+ newLines: number;
+ lines: string[];
+}
+
+export interface EditRecord {
+ /** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
+ id: string;
+ /** ms since epoch, from the result line's `timestamp` (0 when missing). */
+ ts: number;
+ path: string;
+ kind: EditKind;
+ tool: EditTool;
+ /** A `Write` that created the file. */
+ created: boolean;
+ /** Counted over the whole patch, also when `hunks` was cut. */
+ added: number;
+ removed: number;
+ hunks: EditHunk[];
+ truncated: boolean;
+ /** Set for lines of a subagent transcript. */
+ agentId?: string;
+}
+
+export interface PendingBash {
+ id: string;
+ ts: number;
+ command: string;
+ cwd: string;
+ agentId?: string;
+}
+
+export interface ImageCandidate {
+ toolUseId: string;
+ start: number;
+ end: number;
+ paths: string[];
+ agentId?: string;
+ /**
+ * Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
+ * applies; the file must still exist, and a version already listed is not listed again.
+ */
+ sent?: boolean;
+}
+
+export interface EditParseResult {
+ records: EditRecord[];
+ candidates: ImageCandidate[];
+ pending: PendingBash[];
+}
+
+const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
+const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
+// After the extension: no further name character, and no `.ext` (a.png.bak).
+const EXT_END = '(?![\\w-])(?!\\.\\w)';
+// Characters that end an unquoted path token.
+const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
+const PATH_TOKEN_RE = new RegExp(
+ [
+ // 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
+ // The lazy scan stops at the next `file://`: otherwise it runs to the line end from
+ // every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
+ // timestamped name (`plot-12:30.png`) is still found.
+ `file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
+ // 2./3. Quoted paths, spaces allowed.
+ `"([^"\\n]*\\.${IMAGE_EXT})"`,
+ `'([^'\\n]*\\.${IMAGE_EXT})'`,
+ // 4. Unquoted token. A match starts only at a token boundary (line start or a character
+ // that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
+ // name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
+ // some characters let a match start at every other character of a long token
+ // (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
+ // length. The name before the extension must not be empty.
+ `(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
+ ].join('|'),
+ 'gi'
+);
+
+/** `image` for the image extensions the panel can thumbnail, else `code`. */
+export function classifyEditPath(path: string): EditKind {
+ return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
+}
+
+/**
+ * Image-looking paths in a Bash call's command and output, in order of appearance,
+ * deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
+ * `~/` paths stay literal (the host that stats them expands `~`), relative ones are
+ * resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
+ * skipped.
+ */
+export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
+ const text = [command, stdout, stderr].join('\n');
+ const out: string[] = [];
+ const seen = new Set();
+ for (const m of text.matchAll(PATH_TOKEN_RE)) {
+ const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
+ if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
+ let resolved: string;
+ if (raw.startsWith('/')) resolved = posix.normalize(raw);
+ else if (raw.startsWith('~/')) resolved = raw;
+ else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
+ else continue;
+ if (seen.has(resolved)) continue;
+ seen.add(resolved);
+ out.push(resolved);
+ if (out.length >= MAX_IMAGE_CANDIDATES) break;
+ }
+ return out;
+}
+
+function isRecord(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
+}
+
+/**
+ * The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
+ * (`truncated`). Also applied to hunks loaded from a store file.
+ */
+export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
+ const hunks: EditHunk[] = [];
+ let budget = MAX_HUNK_BYTES;
+ let truncated = false;
+ for (const h of patch) {
+ if (!isRecord(h) || !Array.isArray(h.lines)) continue;
+ if (truncated) break;
+ const kept: string[] = [];
+ for (const l of h.lines) {
+ if (typeof l !== 'string') continue;
+ const cost = Buffer.byteLength(l) + 3;
+ if (cost > budget) {
+ truncated = true;
+ break;
+ }
+ budget -= cost;
+ kept.push(l);
+ }
+ hunks.push({
+ oldStart: Number(h.oldStart) || 0,
+ oldLines: Number(h.oldLines) || 0,
+ newStart: Number(h.newStart) || 0,
+ newLines: Number(h.newLines) || 0,
+ lines: kept,
+ });
+ }
+ return { hunks, truncated };
+}
+
+function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
+ let added = 0;
+ let removed = 0;
+ for (const h of patch) {
+ if (!isRecord(h) || !Array.isArray(h.lines)) continue;
+ for (const l of h.lines) {
+ if (typeof l !== 'string') continue;
+ if (l.startsWith('+')) added++;
+ else if (l.startsWith('-')) removed++;
+ }
+ }
+ return { ...capHunks(patch), added, removed };
+}
+
+/** A new file's content as one all-added hunk, in `structuredPatch` form. */
+function createdFileHunk(content: string): Record {
+ const lines = content.split('\n');
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
+ return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
+}
+
+function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
+ if (!isRecord(result)) return null;
+ const { filePath, structuredPatch } = result;
+ if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
+ const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
+ const kind = classifyEditPath(filePath);
+ // A Write that created a file reports an empty patch; its diff is the whole content.
+ const patch =
+ result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
+ ? [createdFileHunk(result.content)]
+ : structuredPatch;
+ const { hunks, added, removed, truncated } = toHunks(patch);
+ const record: EditRecord = {
+ id,
+ ts,
+ path: filePath,
+ kind,
+ tool,
+ created: result.type === 'create',
+ added,
+ removed,
+ hunks,
+ truncated,
+ };
+ if (agentId) record.agentId = agentId;
+ return record;
+}
+
+/**
+ * Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
+ * files claude delivered to the user, which is how a session shows generated images.
+ */
+function sentImagePaths(result: unknown): string[] {
+ if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
+ const out: string[] = [];
+ for (const a of result.attachments) {
+ if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
+ if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
+ }
+ return out;
+}
+
+/** The text of a `tool_result` block's content (a string or a list of text blocks). */
+function blockText(content: unknown): string {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
+ .filter(Boolean)
+ .join('\n');
+}
+
+/**
+ * Parse transcript lines into edit records and Bash image candidates.
+ *
+ * @param text complete JSONL lines; a first line cut mid-record (a byte range starting
+ * inside it) and anything else that does not parse is skipped.
+ * @param pending unanswered Bash calls from the previous chunk.
+ */
+export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
+ const open = new Map(pending.map((p) => [p.id, p]));
+ const records: EditRecord[] = [];
+ const candidates: ImageCandidate[] = [];
+
+ for (const rawLine of text.split('\n')) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ let rec: unknown;
+ try {
+ rec = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (!isRecord(rec)) continue;
+ const message = rec.message;
+ const content = isRecord(message) ? message.content : undefined;
+ if (!Array.isArray(content)) continue;
+ const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
+ const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
+ const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
+ const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
+
+ if (rec.type === 'assistant') {
+ for (const block of content) {
+ if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
+ if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
+ const command = block.input.command;
+ if (typeof command !== 'string') continue;
+ if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
+ const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
+ if (agentId) entry.agentId = agentId;
+ open.delete(block.id);
+ open.set(block.id, entry);
+ while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
+ }
+ continue;
+ }
+
+ if (rec.type !== 'user') continue;
+ const results = content.filter(
+ (b): b is Record => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
+ );
+ // `toolUseResult` describes the line's result; it is unambiguous only for one block.
+ const single = results.length === 1;
+ for (const block of results) {
+ const id = block.tool_use_id as string;
+ if (single) {
+ const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
+ if (edit) records.push(edit);
+ }
+ const sent = single ? sentImagePaths(rec.toolUseResult) : [];
+ if (sent.length > 0) {
+ const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
+ if (agentId) candidate.agentId = agentId;
+ candidates.push(candidate);
+ }
+ const call = open.get(id);
+ if (!call) continue;
+ open.delete(id);
+ const r = single ? rec.toolUseResult : undefined;
+ let stdout: string;
+ let stderr = '';
+ if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
+ stdout = typeof r.stdout === 'string' ? r.stdout : '';
+ stderr = typeof r.stderr === 'string' ? r.stderr : '';
+ } else {
+ stdout = blockText(block.content);
+ }
+ const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
+ if (paths.length === 0) continue;
+ const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
+ if (call.agentId) candidate.agentId = call.agentId;
+ candidates.push(candidate);
+ }
+ }
+
+ return { records, candidates, pending: [...open.values()] };
+}
diff --git a/src/edit-history-store.ts b/src/edit-history-store.ts
new file mode 100644
index 00000000..de2c303a
--- /dev/null
+++ b/src/edit-history-store.ts
@@ -0,0 +1,678 @@
+/**
+ * @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
+ * session, plus where in each transcript file reading stopped.
+ *
+ * Sources are the main transcript (`main`) and each subagent transcript
+ * (`subagents/agent-.jsonl`). Each has a cursor:
+ * - `offset`: the next byte to read forward; always a line boundary.
+ * - `floor`: the lowest byte already parsed; > 0 while older lines are unread
+ * ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
+ * The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
+ * current conversation, a file without one from `budget` bytes before its end, and hands the
+ * bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so
+ * multibyte characters split by a chunk boundary are never decoded half.
+ *
+ * A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
+ * drops the cut first line and leaves `floor` there. A line longer than the budget is
+ * skipped instead of blocking the cursor, reading forward (`midLine`) and backward
+ * (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
+ * That needs every chunk to be a full budget unless it reaches the file's end (forward) or
+ * start (older): a chunk with no line end then means one line longer than the budget. The
+ * caller does not hand over a chunk that a budget shared by several files cut short.
+ *
+ * A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
+ * pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
+ * and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
+ * the file holds one cursor per transcript file of the current conversation, at most
+ * {@link MAX_PENDING_BASH} unanswered Bash calls and at most {@link MAX_AGENTS} subagent labels.
+ *
+ * ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
+ * contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
+ * deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
+ * never fed to `/api/search`, and refused by the attachment, folder and download routes through
+ * `isSensitivePath` (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
+ * {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
+ *
+ * Persisted as JSON under `dataPath('edit-history', '.json')`, written debounced.
+ *
+ * Tests: `test/edit-history-store.test.ts`.
+ *
+ * @module edit-history-store
+ */
+
+import { randomBytes } from 'node:crypto';
+import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { dataPath } from './config/instance.js';
+import {
+ capHunks,
+ MAX_HUNK_BYTES,
+ MAX_PENDING_BASH,
+ MAX_PENDING_COMMAND_CHARS,
+ MAX_PENDING_FIELD_CHARS,
+ parseClaudeEditLines,
+ type EditRecord,
+ type EditTool,
+ type ImageCandidate,
+ type PendingBash,
+} from './edit-history-parse.js';
+
+export const MAX_ENTRIES = 500;
+/** Diff text (and paths) kept per session; see {@link entryBytes}. */
+export const MAX_STORE_BYTES = 4 * 1024 * 1024;
+/** Longest string kept from a subagent's `meta.json` field. */
+export const MAX_AGENT_META_CHARS = 1024;
+/**
+ * Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
+ * {@link MAX_ENTRIES}, so besides the newest label there always is one.
+ */
+export const MAX_AGENTS = MAX_ENTRIES + 1;
+/** Bytes read per file and pass (first read, forward step, "older" step). */
+export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
+/**
+ * A store file larger than this is not parsed (starts empty). One written within the caps
+ * above stays far below it; JSON escaping at most doubles ordinary text.
+ */
+export const MAX_FILE_BYTES = 32 * 1024 * 1024;
+export const MAIN_SOURCE = 'main';
+const SAVE_DEBOUNCE_MS = 1000;
+const FILE_VERSION = 1;
+const NL = 0x0a;
+/** A transcript id as the reader takes it (`.jsonl`): no path separators. */
+const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/;
+/**
+ * Names this process's tmp files (`.json..tmp`); any other tmp file is left over from
+ * an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1).
+ */
+export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`;
+
+/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
+export type EditHistoryTool = EditTool | 'bash' | 'sent';
+
+export interface EditHistoryEntry extends Omit {
+ tool: EditHistoryTool;
+}
+
+export interface SourceCursor {
+ offset: number;
+ floor: number;
+ /** The next forward read starts inside a line longer than the budget; skip to its end. */
+ midLine?: boolean;
+ /** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
+ floorMidLine?: boolean;
+}
+
+export interface AgentMeta {
+ description: string;
+ agentType: string;
+ worktreePath?: string;
+ worktreeBranch?: string;
+}
+
+export interface ByteRange {
+ offset: number;
+ len: number;
+}
+
+export interface ApplyInput {
+ transcriptId: string;
+ /** `main` or `subagents/agent-.jsonl`. */
+ source: string;
+ startOffset: number;
+ chunk: Buffer;
+ /** Size of the file when the chunk was read. */
+ size: number;
+}
+
+export interface ApplyResult {
+ added: number;
+ candidates: ImageCandidate[];
+}
+
+export interface ConfirmedImage {
+ toolUseId: string;
+ path: string;
+ ts: number;
+ agentId?: string;
+ tool?: 'bash' | 'sent';
+}
+
+export interface EditHistoryStoreOptions {
+ /** Directory holding `.json`; default `dataPath('edit-history')`. */
+ dir?: string;
+ debounceMs?: number;
+}
+
+interface StoreFile {
+ version: number;
+ sessionId: string;
+ transcriptId: string | null;
+ cursors: Record;
+ pendingBash: PendingBash[];
+ agents: Record;
+ entries: EditHistoryEntry[];
+}
+
+function isObj(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
+}
+
+/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
+export function entryBytes(e: EditHistoryEntry): number {
+ let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
+ for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
+ return n;
+}
+
+function metaString(v: unknown): string | undefined {
+ return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
+}
+
+/** Parse an `agent-.meta.json` text; null when it is not an object. */
+export function parseAgentMeta(text: string): AgentMeta | null {
+ try {
+ return agentMetaFrom(JSON.parse(text));
+ } catch {
+ return null;
+ }
+}
+
+function isOffset(v: unknown): v is number {
+ return Number.isSafeInteger(v) && (v as number) >= 0;
+}
+
+// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
+// loses the broken parts instead of failing every request or stalling a cursor.
+
+function agentMetaFrom(raw: unknown): AgentMeta | null {
+ if (!isObj(raw)) return null;
+ const meta: AgentMeta = {
+ description: metaString(raw.description) ?? '',
+ agentType: metaString(raw.agentType) ?? '',
+ };
+ const worktreePath = metaString(raw.worktreePath);
+ const worktreeBranch = metaString(raw.worktreeBranch);
+ if (worktreePath) meta.worktreePath = worktreePath;
+ if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
+ return meta;
+}
+
+function cursorFrom(raw: unknown): SourceCursor | null {
+ if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
+ const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
+ if (raw.midLine === true) cursor.midLine = true;
+ if (raw.floorMidLine === true) cursor.floorMidLine = true;
+ return cursor;
+}
+
+const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS;
+
+function pendingBashFrom(raw: unknown): PendingBash | null {
+ if (!isObj(raw)) return null;
+ const { id, ts, command, cwd, agentId } = raw;
+ if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
+ if (agentId !== undefined && !loadedField(agentId)) return null;
+ const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
+ if (agentId) pending.agentId = agentId;
+ return pending;
+}
+
+const ENTRY_KINDS = new Set(['code', 'image']);
+const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
+const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
+
+/**
+ * A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
+ * dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
+ */
+function entryFrom(raw: unknown): EditHistoryEntry | null {
+ if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
+ if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
+ return null;
+ }
+ if (!Array.isArray(raw.hunks)) return null;
+ const { hunks, truncated } = capHunks(raw.hunks);
+ const entry: EditHistoryEntry = {
+ id: raw.id,
+ ts: raw.ts as number,
+ path: raw.path,
+ kind: raw.kind as EditHistoryEntry['kind'],
+ tool: raw.tool as EditHistoryTool,
+ created: raw.created === true,
+ added: countFrom(raw.added),
+ removed: countFrom(raw.removed),
+ hunks,
+ truncated: truncated || raw.truncated === true,
+ };
+ if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
+ return entry;
+}
+
+export class EditHistoryStore {
+ readonly sessionId: string;
+ private readonly file: string;
+ private readonly debounceMs: number;
+ private transcriptId: string | null = null;
+ private cursors = new Map();
+ private pendingBash: PendingBash[] = [];
+ private agents = new Map();
+ private entries = new Map();
+ /** Sum of {@link entryBytes} over `entries`. */
+ private bytes = 0;
+ private saveTimer: NodeJS.Timeout | null = null;
+ /** The last queued write; `remove()` waits for it. */
+ private writing: Promise | null = null;
+ private removed = false;
+
+ constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
+ this.sessionId = sessionId;
+ this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
+ this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
+ }
+
+ /**
+ * A store with the persisted state of `sessionId`; empty when the file is missing,
+ * unreadable or larger than {@link MAX_FILE_BYTES}.
+ */
+ static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise {
+ const store = new EditHistoryStore(sessionId, opts);
+ let data: unknown;
+ try {
+ if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
+ data = JSON.parse(await readFile(store.file, 'utf-8'));
+ } catch {
+ return store;
+ }
+ if (!isObj(data)) return store;
+ if (data.version !== FILE_VERSION) return store;
+ const f = data as unknown as StoreFile;
+ // The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts.
+ store.transcriptId =
+ typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null;
+ // Cursors are offsets into that conversation's files; without it they mean nothing.
+ const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {};
+ for (const [k, raw] of Object.entries(cursors)) {
+ const cursor = cursorFrom(raw);
+ if (cursor) store.cursors.set(k, cursor);
+ }
+ if (Array.isArray(f.pendingBash)) {
+ store.pendingBash = f.pendingBash
+ .map(pendingBashFrom)
+ .filter((p): p is PendingBash => p !== null)
+ .slice(-MAX_PENDING_BASH);
+ }
+ if (Array.isArray(f.entries)) {
+ store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
+ }
+ for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
+ const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
+ if (meta) store.putAgent(k, meta);
+ }
+ return store;
+ }
+
+ /**
+ * Delete the stored history of every session `keep` rejects, and tmp files other processes
+ * left behind (startup: a crash between removing a session's record and deleting its file,
+ * or mid-write; an older build).
+ * Returns how many sessions' files were deleted.
+ */
+ static async sweep(
+ keep: (sessionId: string) => boolean,
+ opts: Pick = {}
+ ): Promise {
+ const dir = opts.dir ?? dataPath('edit-history');
+ let names: string[];
+ try {
+ names = await readdir(dir);
+ } catch {
+ return 0;
+ }
+ let removed = 0;
+ for (const name of names) {
+ const tmp = /\.json\.[\w-]+\.tmp$/.test(name);
+ const stale = tmp
+ ? !name.endsWith(`.json.${TMP_TAG}.tmp`)
+ : name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
+ if (!stale) continue;
+ await unlink(join(dir, name)).catch(() => undefined);
+ if (!tmp) removed++;
+ }
+ return removed;
+ }
+
+ /** Delete the stored history of `sessionId` without loading it. */
+ static async delete(sessionId: string, opts: Pick = {}): Promise {
+ await new EditHistoryStore(sessionId, opts).remove();
+ }
+
+ getTranscriptId(): string | null {
+ return this.transcriptId;
+ }
+
+ getCursor(source: string): SourceCursor | undefined {
+ const c = this.cursors.get(source);
+ return c ? { ...c } : undefined;
+ }
+
+ /**
+ * True while any source has unread older lines that could still be shown. At a cap (no
+ * room left for another entry) an older edit would be dropped the moment it is read, so
+ * there is nothing more to load.
+ */
+ get backfillTruncated(): boolean {
+ if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
+ for (const c of this.cursors.values()) if (c.floor > 0) return true;
+ return false;
+ }
+
+ /** Entries newest first. */
+ list(): EditHistoryEntry[] {
+ return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
+ }
+
+ get(id: string): EditHistoryEntry | undefined {
+ return this.entries.get(id);
+ }
+
+ getAgents(): Record {
+ return Object.fromEntries(this.agents);
+ }
+
+ hasAgentMeta(agentId: string): boolean {
+ return this.agents.has(agentId);
+ }
+
+ /** Record a subagent's `meta.json`; ignored when it does not parse. */
+ setAgentMeta(agentId: string, metaText: string): void {
+ const meta = this.removed ? null : parseAgentMeta(metaText);
+ if (!meta) return;
+ this.putAgent(agentId, meta);
+ this.scheduleSave();
+ }
+
+ /** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
+ olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
+ const cursor = this.cursors.get(source);
+ if (!cursor || cursor.floor <= 0) return null;
+ const offset = Math.max(0, cursor.floor - budget);
+ return { offset, len: cursor.floor - offset };
+ }
+
+ /** Every source with a cursor in the current conversation. */
+ knownSources(): string[] {
+ return [...this.cursors.keys()];
+ }
+
+ /** Sources with older lines left. */
+ sourcesWithOlder(): string[] {
+ return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
+ }
+
+ /**
+ * A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
+ * longer offers older lines. Its entries stay.
+ */
+ dropSource(source: string): void {
+ if (this.removed || !this.cursors.delete(source)) return;
+ this.scheduleSave();
+ }
+
+ /**
+ * Apply a forward chunk: read from the source's cursor, or, without one, its last `budget`
+ * bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor
+ * (replaced) loses the cursor and is read from its end next time. Returns new entries and
+ * Bash image candidates.
+ */
+ apply(input: ApplyInput): ApplyResult {
+ if (this.removed) return { added: 0, candidates: [] };
+ const { transcriptId, source, startOffset, chunk, size } = input;
+ if (transcriptId !== this.transcriptId) {
+ this.transcriptId = transcriptId;
+ this.cursors.clear();
+ this.pendingBash = [];
+ }
+ let cursor = this.cursors.get(source);
+ if (cursor && size < cursor.offset) {
+ this.cursors.delete(source);
+ cursor = undefined;
+ }
+ if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
+
+ const atEof = startOffset + chunk.length >= size;
+ // Bytes before `from` belong to a line that started before this chunk.
+ let from = 0;
+ if (cursor ? cursor.midLine : startOffset > 0) {
+ const nl = chunk.indexOf(NL);
+ if (nl === -1) {
+ // Still inside that line: move past it unless it is the line being written. A first
+ // read began inside it, so the floor does too.
+ if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
+ return { added: 0, candidates: [] };
+ }
+ from = nl + 1;
+ }
+ const lastNl = chunk.lastIndexOf(NL);
+ if (lastNl < from) {
+ if (from > 0) {
+ // The skipped (or cut first) line ended here; the next one is read from its start.
+ this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
+ } else if (!atEof && chunk.length > 0) {
+ // One line longer than the budget: skip it instead of re-reading it forever.
+ this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
+ } else if (!cursor) {
+ this.setCursor(source, undefined, startOffset, startOffset, false);
+ }
+ return { added: 0, candidates: [] };
+ }
+ const end = lastNl + 1;
+ this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
+ const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
+ this.pendingBash = parsed.pending;
+ const added = this.addRecords(parsed.records);
+ this.scheduleSave();
+ return { added, candidates: parsed.candidates };
+ }
+
+ /**
+ * Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It
+ * ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer
+ * than the budget (its part in this chunk is dropped).
+ */
+ applyOlder(input: ApplyInput): ApplyResult {
+ const { transcriptId, source, startOffset, chunk } = input;
+ const cursor = this.removed ? undefined : this.cursors.get(source);
+ if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
+ return { added: 0, candidates: [] };
+ }
+ const none = { added: 0, candidates: [] };
+ let end = chunk.length;
+ if (cursor.floorMidLine) {
+ const lastNl = chunk.lastIndexOf(NL);
+ if (lastNl === -1 && startOffset > 0) {
+ // Still inside the long line.
+ cursor.floor = startOffset;
+ this.scheduleSave();
+ return none;
+ }
+ // The long line starts at `end`: drop its part.
+ end = lastNl + 1;
+ delete cursor.floorMidLine;
+ }
+ let from = 0;
+ if (startOffset > 0) {
+ // The newline at `end - 1` closes a line that may have started before this chunk.
+ const nl = chunk.subarray(0, end).indexOf(NL);
+ if (nl === -1 || nl === end - 1) {
+ if (end < chunk.length) {
+ // It ends where the long line starts: the next block, ending there, holds it whole.
+ cursor.floor = startOffset + end;
+ } else {
+ // No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
+ cursor.floor = startOffset;
+ cursor.floorMidLine = true;
+ }
+ this.scheduleSave();
+ return none;
+ }
+ from = nl + 1;
+ }
+ cursor.floor = startOffset + from;
+ // Calls whose results lie in already-read newer lines cannot be paired here.
+ const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
+ const added = this.addRecords(parsed.records);
+ this.scheduleSave();
+ return { added, candidates: parsed.candidates };
+ }
+
+ /** Add Bash images whose mtime was confirmed inside their call's window. */
+ addImageEntries(images: readonly ConfirmedImage[]): number {
+ if (this.removed) return 0;
+ const records: EditHistoryEntry[] = images.map((img) => {
+ const entry: EditHistoryEntry = {
+ id: `${img.toolUseId}:${img.path}`,
+ ts: img.ts,
+ path: img.path,
+ kind: 'image',
+ tool: img.tool ?? 'bash',
+ created: false,
+ added: 0,
+ removed: 0,
+ hunks: [],
+ truncated: false,
+ };
+ if (img.agentId) entry.agentId = img.agentId;
+ return entry;
+ });
+ const added = this.addRecords(records);
+ if (added) this.scheduleSave();
+ return added;
+ }
+
+ /**
+ * Write pending changes, if any, and wait for a write under way (the store is dropped from
+ * memory and loaded again later). Unlike `flush()`, a store without changes writes nothing.
+ */
+ async settle(): Promise {
+ if (this.saveTimer) return this.flush();
+ await this.writing;
+ }
+
+ /** Write now (cancels a pending debounced write). Writes run one after another. */
+ async flush(): Promise {
+ if (this.saveTimer) {
+ clearTimeout(this.saveTimer);
+ this.saveTimer = null;
+ }
+ const run = (this.writing ?? Promise.resolve()).then(() => this.write());
+ this.writing = run.catch(() => undefined);
+ await run;
+ }
+
+ /** True once {@link remove} ran: the store ignores every later write. */
+ isRemoved(): boolean {
+ return this.removed;
+ }
+
+ /**
+ * Delete the persisted file (session deleted) and forget the content. The store takes no
+ * changes afterwards, so a read still in flight neither writes nor refills it.
+ */
+ async remove(): Promise {
+ this.removed = true;
+ if (this.saveTimer) {
+ clearTimeout(this.saveTimer);
+ this.saveTimer = null;
+ }
+ this.entries.clear();
+ this.bytes = 0;
+ this.cursors.clear();
+ this.pendingBash = [];
+ this.agents.clear();
+ // A write already under way ends first (or sees `removed`), so it cannot rename its
+ // tmp file over the deleted store afterwards.
+ await this.writing;
+ await unlink(this.file).catch(() => undefined);
+ }
+
+ private async write(): Promise {
+ if (this.removed) return;
+ const data: StoreFile = {
+ version: FILE_VERSION,
+ sessionId: this.sessionId,
+ transcriptId: this.transcriptId,
+ cursors: Object.fromEntries(this.cursors),
+ pendingBash: this.pendingBash,
+ agents: Object.fromEntries(this.agents),
+ entries: this.list(),
+ };
+ const dir = join(this.file, '..');
+ await mkdir(dir, { recursive: true, mode: 0o700 });
+ // `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
+ // makes it the store, so an existing store never keeps wider bits.
+ const tmp = `${this.file}.${TMP_TAG}.tmp`;
+ await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
+ if (this.removed) {
+ await unlink(tmp).catch(() => undefined);
+ return;
+ }
+ await rename(tmp, this.file);
+ }
+
+ private putAgent(agentId: string, meta: AgentMeta): void {
+ this.agents.delete(agentId);
+ this.agents.set(agentId, meta);
+ if (this.agents.size <= MAX_AGENTS) return;
+ const used = new Set();
+ for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
+ for (const id of this.agents.keys()) {
+ if (this.agents.size <= MAX_AGENTS) break;
+ // The newest label has no entries yet: they come with its transcript.
+ if (id !== agentId && !used.has(id)) this.agents.delete(id);
+ }
+ }
+
+ private setCursor(
+ source: string,
+ cursor: SourceCursor | undefined,
+ offset: number,
+ firstFloor: number,
+ midLine: boolean,
+ firstFloorMidLine = false
+ ): void {
+ const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
+ if (midLine) next.midLine = true;
+ if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
+ this.cursors.set(source, next);
+ }
+
+ private addRecords(records: readonly EditHistoryEntry[]): number {
+ const fresh: string[] = [];
+ for (const r of records) {
+ if (this.entries.has(r.id)) continue;
+ this.entries.set(r.id, r);
+ this.bytes += entryBytes(r);
+ fresh.push(r.id);
+ }
+ if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
+ for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
+ if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
+ this.entries.delete(e.id);
+ this.bytes -= entryBytes(e);
+ }
+ }
+ // Only what is still there counts: an "older" block past the cap adds nothing.
+ return fresh.filter((id) => this.entries.has(id)).length;
+ }
+
+ private scheduleSave(): void {
+ if (this.removed || this.saveTimer) return;
+ this.saveTimer = setTimeout(() => {
+ this.saveTimer = null;
+ void this.flush().catch((err) => {
+ console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
+ });
+ }, this.debounceMs);
+ this.saveTimer.unref?.();
+ }
+}
diff --git a/src/git-clone.ts b/src/git-clone.ts
index ef3380e8..513d6a11 100644
--- a/src/git-clone.ts
+++ b/src/git-clone.ts
@@ -50,9 +50,10 @@
import { spawn, execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
-import { rename, rm } from 'node:fs/promises';
+import { lstat, readdir, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
+import { probePathKind } from './utils/bounded-path-probe.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -188,8 +189,13 @@ export interface GitFailure {
export interface CloneOptions {
/** Pre-validated operand from `parseGitRepositoryUrl`. */
repository: string;
- /** Absolute destination directory. Must NOT exist; created by git. */
+ /** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
destination: string;
+ /**
+ * Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user
+ * picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused.
+ */
+ intoEmptyDir?: boolean;
/** Optional branch or tag (`--branch [ --single-branch`). */
ref?: string;
/** `--depth 1`: history-less but much faster on large repos. */
@@ -590,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
stderr: clean,
};
}
+ // First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would
+ // otherwise match the host and repository patterns below, and a wrong code here decides a deletion.
+ if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
+ return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
+ }
if (timedOut) {
return {
code: 'TIMEOUT',
@@ -625,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
stderr: clean,
};
}
- if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
+ if (
+ /could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test(
+ lower
+ )
+ ) {
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
}
- if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
- return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
- }
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
}
@@ -701,6 +713,8 @@ interface GitRun {
stderr: string;
code: number | null;
timedOut: boolean;
+ /** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */
+ started: boolean;
spawnError?: string;
}
@@ -720,10 +734,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
const queuedAt = Date.now();
const slot = await acquireGitSlot(timeoutMs);
if (slot === 'queue-full') {
- return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
+ return {
+ stdout: '',
+ stderr: '',
+ code: null,
+ timedOut: false,
+ spawnError: 'EBUSY: git operation queue is full',
+ started: false,
+ };
}
if (slot === 'timed-out') {
- return { stdout: '', stderr: '', code: null, timedOut: true };
+ return { stdout: '', stderr: '', code: null, timedOut: true, started: false };
}
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
try {
@@ -736,7 +757,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
detached: true,
});
} catch (err) {
- resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
+ resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) });
return;
}
@@ -783,8 +804,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
resolve(result);
};
- child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
- child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
+ child.on('error', (err) =>
+ finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) })
+ );
+ child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true }));
});
} finally {
releaseGitSlot();
@@ -847,6 +870,35 @@ export async function probeGitRemote(
};
}
+/** Destinations with a direct (`intoEmptyDir`) clone in flight. */
+const directClones = new Set();
+
+function destinationExists(): CloneResult {
+ return {
+ ok: false,
+ failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
+ };
+}
+
+/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */
+async function isEmptyDirectory(path: string): Promise {
+ try {
+ const st = await lstat(path);
+ return st.isDirectory() && (await readdir(path)).length === 0;
+ } catch {
+ return false;
+ }
+}
+
+/** Remove everything inside `path`, keeping `path` itself. Best effort. */
+async function emptyDirectory(path: string): Promise {
+ try {
+ for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true });
+ } catch {
+ /* nothing to clean, or it vanished */
+ }
+}
+
/**
* Clone `repository` into `destination`.
*
@@ -872,10 +924,33 @@ export async function cloneRepository(opts: CloneOptions): Promise
};
}
if (existsSync(opts.destination)) {
- return {
- ok: false,
- failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
- };
+ // One direct clone per folder at a time: once two gits have written into the same folder their
+ // files cannot be told apart, so the second caller is refused before it spawns anything. Taken
+ // before the emptiness check (which can precede a long wait for a git slot).
+ if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists();
+ directClones.add(opts.destination);
+ try {
+ if (!(await isEmptyDirectory(opts.destination))) return destinationExists();
+ // The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
+ const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
+ if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
+ const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError);
+ // The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but
+ // only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing,
+ // and anything in the folder by then (files the user copied in, another case created there) is not ours.
+ // A mount that stopped answering is left alone too, so a libuv worker is not held on it.
+ // git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on
+ // which pattern matched first.
+ const refused = /already exists and is not an empty directory/i.test(direct.stderr);
+ if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') {
+ if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') {
+ await emptyDirectory(opts.destination);
+ }
+ }
+ return { ok: false, failure };
+ } finally {
+ directClones.delete(opts.destination);
+ }
}
// Sibling of the destination (same filesystem), so the rename is atomic.
diff --git a/src/image-watcher.ts b/src/image-watcher.ts
index 8f8dc560..072a0ee3 100644
--- a/src/image-watcher.ts
+++ b/src/image-watcher.ts
@@ -14,7 +14,7 @@ import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
-import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
+import { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
// ========== Types ==========
@@ -159,14 +159,14 @@ export class ImageWatcher extends EventEmitter {
// Ignore common heavy directories for performance
ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories, and Codeman's
- // own upload folders: a pdf the user handed to the agent is not a file
- // the agent produced.
+ // own upload folders: a pdf the user handed to the agent, by upload or
+ // from Discord, is not a file the agent produced.
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/') ||
- UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
+ [...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`))
) {
return true;
}
diff --git a/src/mux-interface.ts b/src/mux-interface.ts
index 57fa360e..5853e12e 100644
--- a/src/mux-interface.ts
+++ b/src/mux-interface.ts
@@ -271,7 +271,7 @@ export interface PaneCaptureOptions {
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
- * - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
+ * - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -396,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getPaneExit?(muxName: string): PaneExit | undefined;
+ /**
+ * The directory the session's pane was in at the last pane read
+ * (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
+ * batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
+ * path printed in the terminal resolves against it; it is never a
+ * confinement root. A multiplexer without it leaves relative paths resolving
+ * against `workingDir`.
+ */
+ getPaneCurrentPath?(muxName: string): string | undefined;
+
/**
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
* reports, or 0 when it reports none. The exited-agent sweep closes a session
diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts
index 56d8ac2a..a617a961 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';
@@ -258,6 +259,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: an EXPLICIT `undefined` probes the
// entry's own local CLI (gateVersion: claude's probe for claude, the registry resolver for
// the rest, both null under vitest); an explicit `null` means "known to be unresolvable"
diff --git a/src/session.ts b/src/session.ts
index a2b49565..c91a70d5 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -30,6 +30,7 @@
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
+import { realpath } from 'node:fs/promises';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
@@ -71,6 +72,8 @@ import {
import type { GitFetchStatus } from './git-fetch.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
+import { probePathKind } from './utils/bounded-path-probe.js';
+import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import { probeRemoteCliVersion } from './remote-hosts.js';
@@ -398,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */
const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000;
+/** Whether absolute `path` is `root` or lies under it (a lexical compare). */
+function isSameOrUnder(path: string, root: string): boolean {
+ return path === root || path.startsWith(root === '/' ? '/' : `${root}/`);
+}
+
+/**
+ * Rewrite `path` from under `from` to the same place under `to`; a path not at or
+ * under `from` comes back unchanged. Exported for unit testing.
+ */
+export function rebasePath(path: string, from: string, to: string): string {
+ if (path === from) return to;
+ if (path.startsWith(`${from}/`)) return to + path.slice(from.length);
+ return path;
+}
+
/**
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
* client can spawn at the same size and avoid the resize-flicker / scrollback
@@ -743,6 +761,21 @@ export class Session extends EventEmitter {
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
+ /**
+ * The pane's live directory, from the pane-exit watcher's read. Runtime-only:
+ * serialized by toState() but never restored by the constructor.
+ */
+ private _paneCwd: string | undefined;
+ /**
+ * `workingDir` with symlinks resolved, so a pane reading (tmux reads the
+ * process cwd, always the resolved path) can be put back into `workingDir`'s
+ * spelling. `undefined` until the first resolution settles; `null` when there
+ * is nothing to translate (no symlink in the path) or it could not be resolved.
+ */
+ private _workspaceRealPath: string | null | undefined;
+ private _workspaceRealPathPending = false;
+ /** When a resolution that met an unreachable folder may be tried again (0 = never). */
+ private _workspaceRealPathRetryAt = 0;
/**
* How many starts, attaches or relaunches are running for this session's
* pane. While one is, a dead-pane reading may describe a pane that is being
@@ -1330,6 +1363,17 @@ export class Session extends EventEmitter {
return value && value.trim() ? value.trim() : undefined;
}
+ /**
+ * One env var from this session's overrides, for internal path resolution only
+ * (custom-model injection linking an isolated config dir back to a path that must
+ * survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the
+ * map can hold provider credentials and stays off the public `SessionState`.
+ */
+ envOverrideValue(key: string): string | undefined {
+ const value = this._envOverrides?.[key];
+ return value && value.trim() ? value.trim() : undefined;
+ }
+
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
@@ -1485,6 +1529,99 @@ export class Session extends EventEmitter {
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
+ /** The pane's live working directory, when the pane read has reported one. */
+ get paneCwd(): string | undefined {
+ return this._paneCwd;
+ }
+
+ /**
+ * Record the pane's live directory. Returns whether it CHANGED, so the caller
+ * broadcasts only on a real transition.
+ *
+ * Scoped by {@link paneExitApplies}, the same rule as the exit reading: a
+ * remote session's local pane runs the ssh client and a docker case's runs a
+ * `docker exec`, so their pane directory is a host directory the agent was
+ * never in, and a direct-PTY or rediscovered session has no pane Codeman can
+ * vouch for. A relative value is refused rather than stored.
+ *
+ * tmux reads the pane's directory from the process cwd, which is always the
+ * resolved path, while `workingDir` keeps the spelling the case was created
+ * with. A workspace reached through a symlink would otherwise read as outside
+ * itself, and the browser's preview routing (a lexical compare against
+ * `workingDir`) would send every relative link to the attachment route. So a
+ * reading at or under the workspace's resolved path is written back in
+ * `workingDir`'s spelling. Until that resolution has settled, only a reading
+ * lexically inside `workingDir` is accepted.
+ */
+ setPaneCwd(next: string | undefined): boolean {
+ let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
+ if (resolved !== undefined) {
+ if (
+ this._workspaceRealPath === undefined ||
+ (this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt)
+ ) {
+ this._resolveWorkspaceRealPath();
+ }
+ if (this._workspaceRealPath === undefined) {
+ // The workspace's resolved path is not known yet. A reading outside
+ // `workingDir` could be the workspace itself seen through its symlink, so
+ // it waits for the next tick rather than mis-route links for one.
+ if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false;
+ } else if (this._workspaceRealPath !== null) {
+ resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling());
+ }
+ }
+ if (resolved === this._paneCwd) return false;
+ this._paneCwd = resolved;
+ return true;
+ }
+
+ /** `workingDir` without a trailing separator (the root stays `/`). */
+ private _workspaceSpelling(): string {
+ return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
+ }
+
+ /**
+ * Resolve `workingDir`'s symlinks in the background for {@link setPaneCwd}.
+ *
+ * Bounded: the folder can sit on a network mount that stopped answering, so it
+ * is probed with `probePathKind()` first and the `realpath()` is raced against
+ * the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no
+ * translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a
+ * folder that is absent or not a directory is settled for good.
+ */
+ private _resolveWorkspaceRealPath(): void {
+ if (this._workspaceRealPathPending) return;
+ this._workspaceRealPathPending = true;
+ const spelling = this._workspaceSpelling();
+ void (async () => {
+ let real: string | null = null;
+ let retry = false;
+ try {
+ const kind = await probePathKind(spelling);
+ if (kind === 'unknown') {
+ retry = true;
+ } else if (kind === 'directory') {
+ let timer: ReturnType | undefined;
+ const answer = await Promise.race([
+ realpath(spelling).catch(() => undefined),
+ new Promise((resolveTimeout) => {
+ timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS);
+ timer.unref?.();
+ }),
+ ]).finally(() => clearTimeout(timer));
+ if (answer === undefined) retry = true;
+ else if (answer !== spelling && answer !== '/') real = answer;
+ }
+ } catch {
+ retry = true;
+ }
+ this._workspaceRealPath = real;
+ this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0;
+ this._workspaceRealPathPending = false;
+ })();
+ }
+
/**
* Record what the mux layer observed of this pane's agent, and say whether
* that changed the answer. The caller persists and broadcasts on a true.
@@ -2162,6 +2299,7 @@ export class Session extends EventEmitter {
pid: this.pid,
status: this._status,
workingDir: this.workingDir,
+ paneCwd: this._paneCwd,
remote: this._remote,
docker: this._docker,
owner: this._owner,
@@ -2692,7 +2830,7 @@ export class Session extends EventEmitter {
if (entry?.launch.chain !== 'fallback') return options;
const resumeIdPattern = entry.launch.params?.resumeId;
- const configDir = this._claudeConfigDir();
+ const configDir = this.claudeConfigDir();
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
@@ -2714,8 +2852,11 @@ export class Session extends EventEmitter {
return options;
}
- /** The session's Claude config dir when it has been relocated (#255), else undefined. */
- private _claudeConfigDir(): string | undefined {
+ /**
+ * The session's Claude config dir when it has been relocated (#255), else undefined.
+ * Public for the edit history reader, which looks for the transcript there.
+ */
+ claudeConfigDir(): string | undefined {
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
// envOverrides schema validates keys only, and a whitespace-only value would
// otherwise resolve to a relative path and read "no transcript" for everything.
@@ -2724,7 +2865,8 @@ export class Session extends EventEmitter {
/**
* Force the custom-model selection's `launchModel` (pi/omp `custom/`, grok's
- * `[model.]` block name) onto the CLI's `model` launch param. Where that param
+ * `[model.]` block name, codex's bare selected id) onto the CLI's `model`
+ * launch param. Where that param
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
* ...) or the top-level `model` for an entry that declares none — so this stays a
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 3b59fe2e..41392c92 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map = new Map()
+): Map {
+ const panesPerSession = new Map();
+ for (const row of rows) {
+ panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
+ }
+ const paths = new Map();
+ for (const row of rows) {
+ if (panesPerSession.get(row.sessionName) !== 1) continue;
+ const path = row.currentPath ?? previous.get(row.sessionName);
+ if (!path || !path.startsWith('/')) continue;
+ paths.set(row.sessionName, path);
+ }
+ return paths;
+}
+
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
@@ -2006,6 +2046,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* raw pane reading.
*/
private paneExits: Map = new Map();
+ /**
+ * muxName -> `#{pane_current_path}` from the last authoritative pane read
+ * ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
+ * so it is bounded by the panes on the socket. Like {@link paneExits} it holds
+ * the raw reading; `Session.setPaneCwd()` decides which sessions may use it.
+ */
+ private paneCurrentPaths: Map = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
@@ -3508,6 +3555,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
+ /**
+ * The directory the session's pane was in at the last authoritative pane
+ * read, or `undefined` when unknown. Costs nothing: it reads the map the
+ * pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
+ * per click. A failed or empty read keeps the previous answers, and so does
+ * a blank `#{pane_current_path}` on a single-pane session. After a successful
+ * read, `undefined` means that read listed no single directory for the
+ * session: it was split into several panes or is no longer on the socket.
+ */
+ getPaneCurrentPath(muxName: string): string | undefined {
+ return this.paneCurrentPaths.get(muxName);
+ }
+
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
@@ -3569,6 +3629,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.paneExitReadInFlight = false;
}
if (rows.length === 0) return;
+ // The directory reading rides the same read. It is applied before the
+ // generation check below: a respawn invalidates an exit observation, but the
+ // pane's directory at read time is still the best answer there is. A blank
+ // field on a single-pane session carries the previous answer forward.
+ this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths);
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
diff --git a/src/types/discord.ts b/src/types/discord.ts
new file mode 100644
index 00000000..42782aca
--- /dev/null
+++ b/src/types/discord.ts
@@ -0,0 +1,60 @@
+/**
+ * @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;
+}
+
+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 & {
+ 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 1af34926..08e30f7f 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/types/session.ts b/src/types/session.ts
index ce75091a..ca0f6503 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -840,6 +840,19 @@ export interface SessionState {
status: SessionStatus;
/** Working directory path */
workingDir: string;
+ /**
+ * The directory the session's local tmux pane is in NOW (`#{pane_current_path}`),
+ * when known. `workingDir` is fixed at create time and never follows a `cd`;
+ * this is what a relative path printed in the terminal is relative to.
+ *
+ * Runtime-only: refreshed from the pane-exit watcher's read and never restored
+ * from disk, so a restart starts blank. Absent for remote and docker sessions
+ * (their local pane runs the ssh / docker-exec client) and for direct-PTY or
+ * rediscovered sessions. NOT a confinement root: the file routes stay confined
+ * to `workingDir`, and a path resolved outside it goes through the attachment
+ * routes like any other out-of-workspace path.
+ */
+ paneCwd?: string;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md
index ed210de6..7f5ab0bf 100644
--- a/src/web/CLAUDE.md
+++ b/src/web/CLAUDE.md
@@ -84,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
-⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
+⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
diff --git a/src/web/case-path.ts b/src/web/case-path.ts
index 369464d9..e1219b66 100644
--- a/src/web/case-path.ts
+++ b/src/web/case-path.ts
@@ -60,6 +60,8 @@ export interface NewCasePathContext {
* listed as a local case, so it must not be registered as a linked one too.
*/
casesDirs?: readonly string[];
+ /** What to do instead when the path is the cases folder or directly in it. Defaults to Create New's wording. */
+ casesDirAdvice?: string;
}
export type NewCasePathResult =
@@ -106,10 +108,10 @@ export function blockedReason(
// refusing everything below it blocked most of the tree.
for (const dir of ctx.casesDirs ?? []) {
if (absPath === dir) {
- return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`;
+ return `That is the cases folder itself (${dir}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`;
}
if (dirname(absPath) === dir) {
- return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`;
+ return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; ${ctx.casesDirAdvice ?? 'create it there with plain Create New (no custom folder)'}`;
}
}
return null;
@@ -132,7 +134,7 @@ async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathCont
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
]);
- return [{ home, dataDir, casesDirs }, systemRoots];
+ return [{ home, dataDir, casesDirs, casesDirAdvice: ctx.casesDirAdvice }, systemRoots];
}
/**
diff --git a/src/web/edit-history-sync.ts b/src/web/edit-history-sync.ts
new file mode 100644
index 00000000..ad525c77
--- /dev/null
+++ b/src/web/edit-history-sync.ts
@@ -0,0 +1,497 @@
+/**
+ * @fileoverview Reads a claude session's edit history on request: the new bytes of its
+ * transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
+ * candidates that came with them are confirmed against the files on disk.
+ *
+ * WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
+ * session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
+ * looks at. One sync per session runs at a time; requests during a run are folded into one
+ * more forward read and/or one more older read afterwards.
+ *
+ * HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
+ * the shared budget cut short is not applied (the store would take a chunk with no line end
+ * for a line longer than the budget) but read in the next pass; up to
+ * {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
+ *
+ * NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume),
+ * the previous transcript (same directory) is first read to its end, so edits it got after
+ * the last read are not lost with its cursors; then the new one is read from its end.
+ * "Load older" reaches back within the current conversation only.
+ *
+ * MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently
+ * used one without a run is written (if it changed) and dropped, and loaded again on demand.
+ *
+ * GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file
+ * and then drops the session's slot: a run in flight stops after its current read and its
+ * store no longer writes; a request during the removal reads nothing (`unavailable`, reason
+ * `removed`). A session that comes back with the same id (reboot restore) starts afresh.
+ * `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
+ * subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
+ *
+ * OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
+ * with older lines per click; with more sources they take turns.
+ *
+ * WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
+ * `unsupported` and are never read.
+ *
+ * IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
+ * (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
+ *
+ * Tests: `test/edit-history-sync.test.ts`.
+ *
+ * @module web/edit-history-sync
+ */
+
+import { basename, dirname, join } from 'node:path';
+import type { SessionRemote } from '../types/session.js';
+import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
+import type { ImageCandidate } from '../edit-history-parse.js';
+import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
+import { getCli } from '../config/cli-registry/registry.js';
+
+/** Slack around a Bash call's window for the image mtime check. */
+export const IMAGE_MTIME_SLACK_MS = 2000;
+/** Forward passes per sync when files grew by more than one budget. */
+export const MAX_FORWARD_PASSES = 3;
+/** Sources read back per "Load older" click, a full budget each. */
+export const MAX_OLDER_SOURCES = 3;
+/** Stores kept in memory; the least recently used one past this is dropped. */
+export const MAX_LOADED_STORES = 3;
+/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
+export const NO_TRANSCRIPT = 'no-transcript';
+/** `reason` once the session's history was deleted. */
+export const REMOVED = 'removed';
+
+export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
+
+export interface EditHistoryStatus {
+ state: EditHistoryState;
+ reason?: string;
+}
+
+/** The slice of a session this needs. */
+export interface EditHistorySession {
+ id: string;
+ mode: string;
+ remote: SessionRemote | undefined;
+ docker?: unknown;
+ claudeSessionId: string | null;
+ /** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
+ claudeConfigDir?: string;
+}
+
+export interface EditHistorySyncDeps {
+ /** The session's main transcript path, or null when it has none yet. */
+ transcriptPath(session: EditHistorySession): Promise;
+ readSources(
+ mainPath: string,
+ requests: readonly EditSourceRequest[],
+ budget: number,
+ opts?: EditSourceReadOptions
+ ): Promise;
+ probe(paths: readonly string[]): Promise>;
+ loadStore(sessionId: string): Promise;
+ /** Delete a session's stored history without loading it. */
+ deleteStore(sessionId: string): Promise;
+ /** Delete the stored history of every session `keep` rejects; returns how many. */
+ sweepStores(keep: (sessionId: string) => boolean): Promise;
+ budget?: number;
+}
+
+export interface SyncOptions {
+ older?: boolean;
+}
+
+/** Why a session has no edit history, or undefined when it can have one. */
+export function unsupportedReason(session: EditHistorySession): string | undefined {
+ if (session.remote) return 'remote';
+ if (session.docker) return 'docker';
+ if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
+ return undefined;
+}
+
+/** One turn of the event loop, between synchronous parses of up to one budget each. */
+function breathe(): Promise {
+ return new Promise((resolve) => setImmediate(resolve));
+}
+
+/**
+ * Bash candidates whose probed file is a regular file with an mtime inside the call's window
+ * (files claude sent to the user skip the window: they exist and were shown on purpose).
+ * A candidate whose file version is already recorded is skipped: `known(path)` is the newest
+ * entry time for that path, and an entry at or after `mtime − slack` saw this version: the
+ * `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
+ * in call order so a batch dedupes itself the same way.
+ */
+export function confirmImages(
+ candidates: readonly ImageCandidate[],
+ probe: (path: string) => PathProbe | null | undefined,
+ known: (path: string) => number | undefined = () => undefined
+): ConfirmedImage[] {
+ const out: ConfirmedImage[] = [];
+ const seen = new Map();
+ for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
+ for (const path of c.paths) {
+ const p = probe(path);
+ if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
+ const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
+ if (!c.sent && !inWindow) continue;
+ const entryPath = path.startsWith('~/') ? p.realPath : path;
+ const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
+ if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
+ seen.set(entryPath, c.end);
+ const img: ConfirmedImage = {
+ toolUseId: c.toolUseId,
+ path: entryPath,
+ ts: c.end,
+ tool: c.sent ? 'sent' : 'bash',
+ };
+ if (c.agentId) img.agentId = c.agentId;
+ out.push(img);
+ }
+ }
+ return out;
+}
+
+interface SyncWant {
+ forward: boolean;
+ older: boolean;
+}
+
+interface SessionSlot {
+ store?: Promise;
+ status: EditHistoryStatus;
+ running?: Promise;
+ again?: SyncWant;
+ /** The history was deleted: nothing reads for this session any more. */
+ gone?: boolean;
+ /** The last source the previous "Load older" read; the next one starts after it. */
+ olderAfter?: string;
+}
+
+export class EditHistorySync {
+ /** By session, least recently used first. */
+ private readonly slots = new Map();
+ /** Writes of dropped stores; loading that session again waits for its write. */
+ private readonly unloading = new Map>();
+ private readonly budget: number;
+
+ constructor(private readonly deps: EditHistorySyncDeps) {
+ this.budget = deps.budget ?? READ_BUDGET_BYTES;
+ }
+
+ /** The session's store, loaded from disk on first use (one load for concurrent callers). */
+ getStore(sessionId: string): Promise {
+ return this.storeOf(sessionId, this.slot(sessionId));
+ }
+
+ getStatus(sessionId: string): EditHistoryStatus {
+ return { ...this.slot(sessionId).status };
+ }
+
+ /**
+ * Read what is new (or, with `older`, the next older block). Resolves when this
+ * request's run is done; never rejects. A call during a run queues one more run.
+ */
+ async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise {
+ const why = unsupportedReason(session);
+ const slot = this.slot(session.id);
+ if (slot.gone) return { ...slot.status };
+ if (why) {
+ slot.status = { state: 'unsupported', reason: why };
+ return { ...slot.status };
+ }
+ const want: SyncWant = { forward: !opts.older, older: !!opts.older };
+ if (slot.running) {
+ slot.again = {
+ forward: (slot.again?.forward ?? false) || want.forward,
+ older: (slot.again?.older ?? false) || want.older,
+ };
+ await slot.running;
+ // The queued run starts when the current one ends; wait for it too.
+ if (slot.running) await slot.running;
+ return { ...slot.status };
+ }
+ slot.running = this.runLoop(session, slot, want);
+ try {
+ await slot.running;
+ } finally {
+ slot.running = undefined;
+ this.unloadIdle(slot);
+ }
+ return { ...slot.status };
+ }
+
+ /** Write every loaded store's pending changes (server shutdown). */
+ async flushAll(): Promise {
+ await Promise.all([
+ ...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
+ ...this.unloading.values(),
+ ]);
+ }
+
+ /**
+ * Delete a session's stored history (its record is gone). While it runs the session is
+ * marked, so a run in flight or a request meanwhile cannot load and write a new store for
+ * it; then its slot goes, and a session that comes back with this id starts afresh.
+ */
+ async remove(sessionId: string): Promise {
+ const slot = this.slot(sessionId);
+ slot.gone = true;
+ slot.again = undefined;
+ slot.status = { state: 'unavailable', reason: REMOVED };
+ try {
+ const store = await slot.store?.catch(() => undefined);
+ if (store) {
+ await store.remove();
+ } else {
+ await this.unloading.get(sessionId);
+ await this.deps.deleteStore(sessionId);
+ }
+ await slot.running;
+ } finally {
+ if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId);
+ }
+ }
+
+ /**
+ * Delete the stored history of sessions that no longer exist (startup: left behind by a
+ * crash during deletion, or by an older build). A session in use here is kept.
+ */
+ sweep(exists: (sessionId: string) => boolean): Promise {
+ return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
+ }
+
+ /** The session's slot, now the most recently used. */
+ private slot(sessionId: string): SessionSlot {
+ const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
+ this.slots.delete(sessionId);
+ this.slots.set(sessionId, slot);
+ return slot;
+ }
+
+ private storeOf(sessionId: string, slot: SessionSlot): Promise {
+ if (!slot.store) {
+ const loading = (async () => {
+ await this.unloading.get(sessionId);
+ return this.deps.loadStore(sessionId);
+ })();
+ slot.store = loading;
+ loading.catch(() => {
+ if (slot.store === loading) slot.store = undefined;
+ });
+ this.unloadIdle(slot);
+ }
+ return slot.store;
+ }
+
+ /**
+ * Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is
+ * written if it changed, and a later request loads it again (after that write).
+ */
+ private unloadIdle(keep?: SessionSlot): void {
+ let loaded = 0;
+ for (const slot of this.slots.values()) if (slot.store) loaded++;
+ for (const [sessionId, slot] of this.slots) {
+ if (loaded <= MAX_LOADED_STORES) break;
+ if (!slot.store || slot.running || slot.gone || slot === keep) continue;
+ this.slots.delete(sessionId);
+ loaded--;
+ const writing: Promise = slot.store
+ .then((store) => store.settle())
+ .catch((err) => {
+ console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err);
+ })
+ .finally(() => {
+ if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId);
+ });
+ this.unloading.set(sessionId, writing);
+ }
+ }
+
+ private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise {
+ let next: SyncWant | undefined = want;
+ while (next && !slot.gone) {
+ if (next.forward) await this.runOnce(session, slot, false);
+ if (next.older && !slot.gone) await this.runOnce(session, slot, true);
+ next = slot.again;
+ slot.again = undefined;
+ }
+ }
+
+ private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise {
+ let status: EditHistoryStatus;
+ try {
+ // This slot's store: a run still in flight after `remove()` cannot reach a later one.
+ const store = await this.storeOf(session.id, slot);
+ const path = slot.gone ? null : await this.deps.transcriptPath(session);
+ let found: boolean;
+ if (!path) {
+ found = false;
+ } else if (older) {
+ found = await this.readOlder(path, store, slot);
+ } else {
+ found = true;
+ for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
+ const r = await this.readForward(path, store);
+ found = r.found;
+ if (!r.more) break;
+ }
+ }
+ status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
+ } catch (err) {
+ status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
+ }
+ if (!slot.gone) slot.status = status;
+ }
+
+ private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
+ const out: EditSourceRequest[] = [];
+ for (const relPath of store.knownSources()) {
+ const c = store.getCursor(relPath);
+ if (c) out.push({ relPath, offset: c.offset });
+ }
+ return out;
+ }
+
+ /**
+ * One forward read of all sources. `more` = some file has unread bytes after it, or was cut
+ * short by the shared budget and left for the next pass. A transcript other than the stored
+ * one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
+ */
+ private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
+ const own = this.storedTranscript(path, store);
+ if (own !== path && store.knownSources().length > 0) {
+ const previous = await this.readFrom(own, store, this.forwardRequests(store));
+ if (previous.more) return { found: true, more: true };
+ }
+ return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
+ }
+
+ /**
+ * The path of the conversation the store holds cursors for: `path` itself, or the previous
+ * conversation's transcript next to it (`/clear` and resume keep the project directory).
+ */
+ private storedTranscript(path: string, store: EditHistoryStore): string {
+ const known = store.getTranscriptId();
+ return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
+ }
+
+ private async readFrom(
+ path: string,
+ store: EditHistoryStore,
+ requests: EditSourceRequest[]
+ ): Promise<{ found: boolean; more: boolean }> {
+ const res = await this.deps.readSources(path, requests, this.budget);
+ if (!res) return { found: false, more: false };
+ this.dropMissing(store, requests, res);
+ for (const [agentId, text] of Object.entries(res.metas)) {
+ if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
+ }
+ const requested = new Set(requests.map((r) => r.relPath));
+ let more = false;
+ const candidates: ImageCandidate[] = [];
+ let parsed = false;
+ for (const f of res.files) {
+ // Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
+ if (parsed) await breathe();
+ if (store.isRemoved()) break;
+ // A requested file reads on from its cursor, a new one its tail.
+ const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
+ if (f.chunk.length < Math.min(this.budget, available)) {
+ more = true;
+ continue;
+ }
+ parsed = true;
+ const r = store.apply({
+ transcriptId: res.transcriptId,
+ source: f.relPath,
+ startOffset: f.startOffset,
+ chunk: f.chunk,
+ size: f.size,
+ });
+ candidates.push(...r.candidates);
+ if (f.startOffset + f.chunk.length < f.size) more = true;
+ }
+ await this.addImages(store, candidates);
+ return { found: true, more };
+ }
+
+ private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise {
+ if (!store.backfillTruncated) return true;
+ // Older lines belong to the stored conversation, even before a forward read moved on.
+ path = this.storedTranscript(path, store);
+ const sources = store.sourcesWithOlder().sort();
+ if (sources.length === 0) return true;
+ // Take turns: start after the last source the previous click read.
+ const after = slot.olderAfter;
+ const first =
+ after === undefined
+ ? 0
+ : Math.max(
+ 0,
+ sources.findIndex((s) => s > after)
+ );
+ const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
+ const requests: EditSourceRequest[] = [];
+ for (const relPath of picked) {
+ const range = store.olderRange(relPath, this.budget);
+ if (range) requests.push({ relPath, offset: range.offset, len: range.len });
+ }
+ if (requests.length === 0) return true;
+ slot.olderAfter = picked[picked.length - 1];
+ const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
+ if (!res) return false;
+ if (res.transcriptId !== store.getTranscriptId()) return true;
+ this.dropMissing(store, requests, res);
+ const wanted = new Set(requests.map((r) => r.relPath));
+ const candidates: ImageCandidate[] = [];
+ let parsed = false;
+ for (const f of res.files) {
+ if (!wanted.has(f.relPath)) continue;
+ // Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
+ if (parsed) await breathe();
+ parsed = true;
+ if (slot.gone) return true;
+ const r = store.applyOlder({
+ transcriptId: res.transcriptId,
+ source: f.relPath,
+ startOffset: f.startOffset,
+ chunk: f.chunk,
+ size: f.size,
+ });
+ candidates.push(...r.candidates);
+ }
+ await this.addImages(store, candidates);
+ return true;
+ }
+
+ /** Requested files the reader no longer lists are gone: their cursors go too. */
+ private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
+ const listed = new Set(res.files.map((f) => f.relPath));
+ for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
+ }
+
+ private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise]