diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 14e5712dc..6bee8a401 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -9,8 +9,8 @@
{
"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 only inside a Codeman-managed session.",
- "version": "1.45.0",
+ "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.46.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e08fd259..787666a88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# aicodeman
+## 1.46.0
+
+### Minor Changes
+
+- de1aab9: ### Thanks
+ - @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
+ - @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
+ - @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
+ - @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
+
+ **Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
+
+ **Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
+
+ **The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
+
+ **Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
+
+ **Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
+
+ **`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
+
+ **Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
+
+ **Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
+
+ **Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
+
+ **Fixes.**
+ - **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
+ - **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
+ - **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
+ - **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
+
+ **Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
+
+ **Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
+
## 1.45.0
### Minor Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index bd2594acd..d0766a670 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.45.0 (must match `package.json`)
+**Version**: 1.46.0 (must match `package.json`)
## Project Overview
@@ -129,9 +129,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
-**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 a CLIENT: no new route, 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), 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`
+**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`
-**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`; no new route). 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`.
+**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)
@@ -175,7 +175,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
-**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
+**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
@@ -189,7 +189,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
-**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `: ` form, so the case identity and `w` 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 and `POST /api/sessions/:id/input` 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` 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)
**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,7 +226,9 @@ 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 adds `~/`, relative and folder paths without overlapping a rooted match or a 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 `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)
+
+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)
@@ -234,7 +236,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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)
-**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
+**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
+
+**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).
**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 `
@@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
| | |
|---|---|
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
-| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
+| **Pixel-matched to the canvas** | Each character is its own ``, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. |
+| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. |
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
-| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
-| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
+| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
+| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. |
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
@@ -128,7 +129,7 @@ This library answers all three:
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
-- **one absolutely-positioned `` per character** at exact cell-grid coordinates
+- **one `` per character**, sized to its cells and snapped to the cell grid
---
@@ -205,6 +206,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
+| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. |
### Backspace handling
@@ -508,15 +510,23 @@ div.xterm-screen (position: relative)
### Per-character grid alignment
-Each character is an absolutely-positioned ``:
+Each line is an absolutely-positioned `
` at its row, and each character is a `` in a flex row inside it, as wide as the cells it covers:
```
-left = visualColumn * cellWidth (CSS pixels)
-top = lineIndex * cellHeight (CSS pixels)
-width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
+line top = lineIndex * cellHeight (CSS pixels)
+edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel)
+span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters)
```
-Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
+A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`.
+
+The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited.
+
+### Rendering cost
+
+Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element.
+
+Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames).
### Font matching
@@ -543,7 +553,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re
## Known limitations
-- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
+- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px.
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json
index 955a1be8b..ac77f48e5 100644
--- a/packages/xterm-zerolag-input/package.json
+++ b/packages/xterm-zerolag-input/package.json
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
- "version": "0.4.1",
+ "version": "0.4.2",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts
index 7e8d7a07b..baa1bab23 100644
--- a/packages/xterm-zerolag-input/src/overlay-renderer.ts
+++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts
@@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
// ─── Overlay rendering ────────────────────────────────────────────────
+/**
+ * Cell edges are snapped to this fraction of a CSS pixel.
+ *
+ * The glyph spans sit in normal flow, so each one starts where the widths
+ * before it add up to, and a width the layout engine cannot store exactly
+ * would carry its rounding error into every later cell (the drift that plain
+ * DOM text shows against the canvas grid). A quarter pixel is exact in
+ * Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units),
+ * so the widths always add up to the snapped edge: no drift at any column,
+ * and every edge is within 1/8 px of `col * cellW`.
+ */
+const EDGE_SNAP = 4;
+
+function cellEdge(col: number, cellW: number): number {
+ return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP;
+}
+
+/** What the last render left in a container, so the next one rebuilds only the lines that changed. */
+interface RenderedLines {
+ /** Everything a line's markup depends on besides its own text and position. */
+ styleKey: string;
+ lines: HTMLDivElement[];
+ lineKeys: string[];
+ cursor: HTMLSpanElement | null;
+}
+
+const rendered = new WeakMap();
+
+/**
+ * False once anything else has emptied or edited the container (the addon's
+ * hide path clears it): the children must be exactly the recorded lines, in
+ * order, then the cursor.
+ */
+function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean {
+ const children = container.children;
+ if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false;
+ for (let i = 0; i < state.lines.length; i++) {
+ if (children[i] !== state.lines[i]) return false;
+ }
+ return !state.cursor || children[state.lines.length] === state.cursor;
+}
+
/**
* Render the overlay content into the container element.
*
- * Creates per-character `` elements positioned on an exact grid
- * matching xterm.js's canvas renderer. This avoids sub-pixel drift that
- * occurs with normal DOM text flow.
+ * Each line is a `
` holding one `` per character on the cell grid
+ * of xterm.js's canvas renderer (see makeLine). CJK wide characters get
+ * double-width spans.
*
- * CJK wide characters are rendered with double-width spans.
+ * Only lines whose content or position changed since the previous render
+ * into the same container are rebuilt. Typing a character changes just the
+ * last line, so a keystroke costs one line of DOM work however long the
+ * prompt is; rebuilding everything made each keystroke O(prompt length) in
+ * DOM work, layout and paint, which a long prompt on a phone paid for in
+ * dropped frames.
*/
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
@@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// Code-point offset of each line in the whole text, so the composition
// styling survives the tail slice below.
const lineOffsets: number[] = [];
+ const lineLengths: number[] = [];
{
let offset = 0;
for (const line of lines) {
+ const length = [...line].length;
lineOffsets.push(offset);
- offset += [...line].length;
+ lineLengths.push(length);
+ offset += length;
}
}
let visibleLines = lines;
@@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
container.style.left = '0px';
container.style.top = topRow * cellH + 'px';
- // Clear and rebuild (typically 1-3 line divs, negligible cost)
- container.innerHTML = '';
+ // Reuse the previous render's lines when nothing they depend on but their
+ // own text and position changed; otherwise start from an empty container.
+ const styleKey = [
+ cellW,
+ cellH,
+ font.fontFamily,
+ font.fontSize,
+ font.fontWeight,
+ font.color,
+ font.backgroundColor,
+ font.letterSpacing,
+ terminal?.unicode?.activeVersion ?? '',
+ ].join('\n');
+ let state = rendered.get(container);
+ if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) {
+ container.innerHTML = '';
+ state = { styleKey, lines: [], lineKeys: [], cursor: null };
+ rendered.set(container, state);
+ }
+ // Taken out while the lines change, so it is appended last again below.
+ state.cursor?.remove();
+ state.cursor = null;
+
const fullWidthPx = totalCols * cellW;
for (let i = 0; i < visibleLines.length; i++) {
@@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
- const lineCompositionFrom =
- compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
+ // Clamped to the line, so a line the composition does not reach keeps
+ // the same key (and its element) while the composition moves along.
+ let lineCompositionFrom: number | undefined;
+ if (compositionStart !== undefined) {
+ const from = compositionStart - lineOffsets[firstVisible + i];
+ if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from);
+ }
+ // Text goes last, so no character in it can make two different lines collide.
+ const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`;
+ if (state.lineKeys[i] === key) continue;
const lineEl = makeLine(
visibleLines[i],
leftPx,
@@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
terminal,
lineCompositionFrom
);
- container.appendChild(lineEl);
+ const previous = state.lines[i];
+ if (previous) container.replaceChild(lineEl, previous);
+ else container.appendChild(lineEl);
+ state.lines[i] = lineEl;
+ state.lineKeys[i] = key;
}
+ for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove();
+ state.lines.length = visibleLines.length;
+ state.lineKeys.length = visibleLines.length;
// Block cursor at end of last line (use visual width for CJK support)
if (showCursor) {
@@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
cursor.style.height = cellH + 'px';
cursor.style.backgroundColor = cursorColor;
container.appendChild(cursor);
+ state.cursor = cursor;
}
}
@@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
}
/**
- * Create a styled line `
` with per-character grid positioning.
+ * Create a styled line `
` with one `` per character on the cell grid.
+ *
+ * The spans sit side by side in a flex row, each exactly as wide as its cells
+ * (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the
+ * widths add up to the grid without drifting. They are deliberately not
+ * absolutely positioned: a positioned element gets its own paint layer, and
+ * with one per character a long prompt left the browser re-layerizing and
+ * repainting thousands of them on every keystroke. Everything the spans share
+ * (font, colour, line height, centring, ligatures off) is set once on the
+ * line and inherited.
*
- * Each character gets its own `` positioned by visual column offset.
- * CJK wide characters occupy 2 cell widths. Characters at or after
- * `compositionFrom` (a code-point index into `text`, may be negative) are IME
- * composition text: underlined, like xterm's own composition view, and marked
- * `data-zerolag-composition` + `aria-hidden` since they are provisional.
+ * Characters at or after `compositionFrom` (a code-point index into `text`)
+ * are IME composition text: underlined, like xterm's own composition view, and
+ * marked `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -190,7 +284,9 @@ function makeLine(
compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
- el.style.cssText = 'position:absolute;pointer-events:none';
+ // No ligatures: the canvas renders each glyph independently.
+ el.style.cssText =
+ "position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0";
el.style.backgroundColor = font.backgroundColor;
el.style.left = leftPx + 'px';
el.style.top = topPx + 'px';
@@ -200,27 +296,24 @@ function makeLine(
// The extra 1px lands in the next row's charTop gap (empty area before
// text rendering starts), so no canvas content is obscured.
el.style.height = cellH + 1 + 'px';
+ el.style.lineHeight = cellH + 'px';
+ el.style.fontFamily = font.fontFamily;
+ el.style.fontSize = font.fontSize;
+ el.style.fontWeight = font.fontWeight;
+ el.style.color = font.color;
+ if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing;
- // CJK wide chars occupy 2 cells — position by visual column offset
- let colOffset = 0;
+ // Each span runs from the previous snapped edge to its own, so the spans
+ // land on the grid by visual column (CJK wide chars span 2 cells).
+ const spanHeight = ';height:' + cellH + 'px';
+ let col = 0;
+ let edge = 0;
let index = 0;
for (const ch of text) {
- const cw = charCellWidth(terminal, ch);
+ col += charCellWidth(terminal, ch);
+ const nextEdge = cellEdge(col, cellW);
const span = document.createElement('span');
- // No ligatures — canvas renders each glyph independently.
- span.style.cssText =
- 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
- "font-feature-settings:'liga' 0,'calt' 0";
- span.style.left = colOffset * cellW + 'px';
- span.style.top = '0px';
- span.style.width = cw * cellW + 'px';
- span.style.height = cellH + 'px';
- span.style.lineHeight = cellH + 'px';
- span.style.fontFamily = font.fontFamily;
- span.style.fontSize = font.fontSize;
- span.style.fontWeight = font.fontWeight;
- span.style.color = font.color;
- if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
+ span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight;
if (compositionFrom !== undefined && index >= compositionFrom) {
span.style.textDecoration = 'underline';
span.setAttribute('data-zerolag-composition', '');
@@ -228,7 +321,7 @@ function makeLine(
}
span.textContent = ch;
el.appendChild(span);
- colOffset += cw;
+ edge = nextEdge;
index++;
}
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
index 3993de8bf..e1320a989 100644
--- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon {
private _bufferDetectDone = false;
// IME text still being composed: drawn after the pending text, never sent.
private _composition = '';
+ // Rows from the top of the screen that are actually visible (see setRowLimit);
+ // null means all of them.
+ private _rowLimit: number | null = null;
// Render cache
private _lastRenderKey = '';
@@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
+ /**
+ * Bound the layout to the first `limit` rows of the screen, or to all of them
+ * with `null`. It narrows the bound the overlay already keeps itself on
+ * screen with (`totalRows`, see _layoutRows), never adds a second one.
+ *
+ * For a host that keeps the terminal taller than the box showing it and
+ * scrolls the screen element instead (e.g. holding the PTY's rows while an
+ * on-screen keyboard covers the bottom): the composer must grow upward from
+ * the last row the user can SEE, not from the terminal's last row, which is
+ * under the keyboard.
+ */
+ setRowLimit(limit: number | null): void {
+ const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null;
+ if (next === this._rowLimit) return;
+ this._rowLimit = next;
+ this.rerender();
+ }
+
+ /**
+ * The `totalRows` bound handed to renderOverlay: the one place that decides
+ * where the block stops growing downward. The terminal's rows, narrowed by
+ * setRowLimit when the host shows fewer; a limit above the terminal's rows
+ * changes nothing.
+ */
+ private _layoutRows(): number {
+ const rows = this._terminal!.rows;
+ return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows;
+ }
+
/**
* Re-read font properties from the terminal and re-render.
* Call after font size changes, theme changes, etc.
@@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon {
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
- // (see renderOverlay), so a keyboard opening — which changes rows without
- // changing the text — must not be skipped as a redundant render.
- const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
+ // (see renderOverlay), so a keyboard opening, which changes rows or the
+ // row limit without changing the text, must not be skipped as a
+ // redundant render.
+ const totalRows = this._layoutRows();
+ const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon {
charTop,
charHeight,
promptRow: activePrompt.row,
- totalRows: this._terminal.rows,
+ totalRows,
font: this._font,
showCursor: this._options.showCursor,
cursorColor,
diff --git a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
index 57b815e92..d13fa789d 100644
--- a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
+++ b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts
@@ -14,7 +14,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
-import { createMockTerminal } from './helpers.js';
+import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// ─── Shared fixtures ─────────────────────────────────────────────────
@@ -47,16 +47,7 @@ function makeParams(overrides: Partial = {}): RenderParams {
/** Extract span data from a rendered line div */
function getSpans(lineDiv: HTMLDivElement) {
- const spans: { text: string; left: number; width: number }[] = [];
- for (let i = 0; i < lineDiv.children.length; i++) {
- const span = lineDiv.children[i] as HTMLSpanElement;
- spans.push({
- text: span.textContent || '',
- left: parseFloat(span.style.left),
- width: parseFloat(span.style.width),
- });
- }
- return spans;
+ return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width }));
}
// ─── Addon setup helpers ─────────────────────────────────────────────
@@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => {
it('CJK span widths are exactly 2 * cellW pixels', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
+ renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.width).toBe('16.8px'); // 2 * 8.4
+ expect(span.style.width).toBe('17px'); // 2 * 8.5
});
it('ASCII span widths remain 1 * cellW pixels', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
+ renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.width).toBe('8.4px'); // 1 * 8.4
+ expect(span.style.width).toBe('8.5px'); // 1 * 8.5
+ });
+
+ it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 }));
+ const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement);
+ expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125);
});
it('terminal unicode addon is preferred over fallback when available', () => {
@@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => {
const spans = getSpans(lineDiv);
expect(spans[0].left).toBe(0);
- expect(spans[1].left).toBeCloseTo(8.4, 5);
- expect(spans[2].left).toBeCloseTo(16.8, 5);
+ // Edges are snapped to a quarter pixel, so within 1/8 px of the grid.
+ expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125);
+ expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125);
});
it('ASCII cursor positions at correct column', () => {
diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts
index ceacfc46a..9a06b7088 100644
--- a/packages/xterm-zerolag-input/test/composition.test.ts
+++ b/packages/xterm-zerolag-input/test/composition.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
-import { createMockTerminal } from './helpers.js';
+import { createMockTerminal, lineSpans } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// setComposition(): IME text still being composed, drawn as an underlined tail
@@ -68,8 +68,9 @@ describe('setComposition', () => {
expect(span.style.textDecoration).toBe('underline');
}
// Grid positions continue straight on from the pending text.
- expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
- expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
+ const cells = lineSpans(line);
+ expect(cells[3].left).toBe(3 * CELL_W);
+ expect(cells[4].left).toBe(4 * CELL_W);
expect(overlay.style.display).toBe('');
});
@@ -79,9 +80,10 @@ describe('setComposition', () => {
addon.setComposition('天気');
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
- expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
- expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
- expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
+ const cells = lineSpans(lineDivs(overlay)[0]);
+ expect(cells[3].left).toBe(6 * CELL_W);
+ expect(cells[3].width).toBe(2 * CELL_W);
+ expect(cells[4].left).toBe(8 * CELL_W);
});
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
@@ -115,7 +117,8 @@ describe('setComposition', () => {
expect(compositionText(overlay)).toBe('WXYZ');
const second = Array.from(lines[1].children) as HTMLSpanElement[];
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
- expect(second[0].style.left).toBe('0px');
+ expect(lineSpans(lines[1])[0].left).toBe(0);
+ expect(lines[1].style.left).toBe('0px');
});
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts
index d22295769..878dffb9a 100644
--- a/packages/xterm-zerolag-input/test/helpers.ts
+++ b/packages/xterm-zerolag-input/test/helpers.ts
@@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
},
};
}
+
+/**
+ * The glyph spans of a rendered overlay line, with each one's left edge and
+ * width in px. The spans sit side by side in a flex row, so a span's left
+ * edge is the sum of the widths before it (what the browser lays out), not a
+ * style property of its own; jsdom does no layout, so this adds them up.
+ */
+export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] {
+ const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = [];
+ let left = 0;
+ for (const child of Array.from(lineDiv.children)) {
+ const el = child as HTMLSpanElement;
+ const width = parseFloat(el.style.width);
+ spans.push({ text: el.textContent || '', left, width, el });
+ left += width;
+ }
+ return spans;
+}
diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
index a95846a85..a83d09012 100644
--- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
+++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
+import { lineSpans } from './helpers.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
@@ -38,7 +39,7 @@ describe('renderOverlay', () => {
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
- renderOverlay(container, makeParams({ lines: ['abc'] }));
+ renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 }));
// Line div + cursor span
expect(container.children.length).toBe(2);
@@ -46,17 +47,43 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
- const spanA = lineDiv.children[0] as HTMLSpanElement;
- expect(spanA.textContent).toBe('a');
- expect(spanA.style.left).toBe('0px');
+ const [a, b, c] = lineSpans(lineDiv);
+ expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]);
+ expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]);
+ expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]);
+ });
- const spanB = lineDiv.children[1] as HTMLSpanElement;
- expect(spanB.textContent).toBe('b');
- expect(spanB.style.left).toBe('8.4px');
+ it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['ab'] }));
+ const lineDiv = container.children[0] as HTMLDivElement;
+ expect(lineDiv.style.position).toBe('absolute');
+ expect(lineDiv.style.display).toBe('flex');
+ for (const { el } of lineSpans(lineDiv)) {
+ expect(el.style.position).toBe('');
+ expect(el.style.left).toBe('');
+ // flex: none, so a span is never stretched or squeezed off its cells.
+ expect(el.style.flexGrow).toBe('0');
+ expect(el.style.flexShrink).toBe('0');
+ }
+ });
- const spanC = lineDiv.children[2] as HTMLSpanElement;
- expect(spanC.textContent).toBe('c');
- expect(spanC.style.left).toBe('16.8px');
+ it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => {
+ const container = document.createElement('div');
+ const cellW = 8.4;
+ renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW }));
+ const spans = lineSpans(container.children[0]);
+ let col = 0;
+ for (const span of spans) {
+ // Every width is a whole number of quarter pixels, which Blink, WebKit
+ // and Gecko all store exactly, so the browser adds them up exactly too.
+ expect(Number.isInteger(span.width * 4)).toBe(true);
+ expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125);
+ col += span.text === '你' ? 2 : 1;
+ }
+ // The far edge is still on the grid after 80 columns.
+ const last = spans[spans.length - 1];
+ expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125);
});
it('sets span width to cellW', () => {
@@ -67,7 +94,7 @@ describe('renderOverlay', () => {
expect(span.style.width).toBe('9.5px');
});
- it('applies font styles to spans', () => {
+ it('applies font styles once on the line, for its spans to inherit', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
@@ -83,12 +110,16 @@ describe('renderOverlay', () => {
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
+ expect(lineDiv.style.fontFamily).toBe('Fira Code');
+ expect(lineDiv.style.fontSize).toBe('16px');
+ expect(lineDiv.style.fontWeight).toBe('bold');
+ expect(lineDiv.style.color).toBe('rgb(255, 0, 0)');
+ expect(lineDiv.style.letterSpacing).toBe('0.5px');
+
+ // Inherited, so a span sets none of them itself.
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.fontFamily).toBe('Fira Code');
- expect(span.style.fontSize).toBe('16px');
- expect(span.style.fontWeight).toBe('bold');
- expect(span.style.color).toBe('rgb(255, 0, 0)');
- expect(span.style.letterSpacing).toBe('0.5px');
+ expect(span.style.fontFamily).toBe('');
+ expect(span.style.color).toBe('');
});
it('offsets first line by startCol', () => {
@@ -217,47 +248,49 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
- expect(span.style.lineHeight).toBe('19px');
+ // Set on the line and inherited by the span.
+ expect(lineDiv.style.lineHeight).toBe('19px');
});
- it('span top is 0px (no vertical offset / no transform)', () => {
+ it('span has no vertical offset and no transform', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.top).toBe('0px');
+ // A flex item starts at the top of its line; nothing shifts it down.
+ expect(span.style.top).toBe('');
+ expect(span.style.marginTop).toBe('');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
- it('span disables ligatures via font-feature-settings', () => {
+ // These are inherited properties, set once on the line for every span.
+
+ it('line disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
- expect(span.style.cssText).toContain('font-feature-settings:');
- expect(span.style.cssText).toContain("'liga' 0");
- expect(span.style.cssText).toContain("'calt' 0");
+ expect(lineDiv.style.cssText).toContain('font-feature-settings:');
+ expect(lineDiv.style.cssText).toContain("'liga' 0");
+ expect(lineDiv.style.cssText).toContain("'calt' 0");
});
- it('span has text-align: center for glyph centering', () => {
+ it('line has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.textAlign).toBe('center');
+ expect(lineDiv.style.textAlign).toBe('center');
});
- it('span has pointer-events: none', () => {
+ it('line has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
- const span = lineDiv.children[0] as HTMLSpanElement;
- expect(span.style.pointerEvents).toBe('none');
+ expect(lineDiv.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
@@ -323,20 +356,18 @@ describe('renderOverlay', () => {
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3);
- const spanA = lineDiv.children[0] as HTMLSpanElement;
- expect(spanA.textContent).toBe('a');
- expect(spanA.style.left).toBe('0px');
- expect(spanA.style.width).toBe('10px'); // 1 cell
+ const [spanA, spanCJK, spanB] = lineSpans(lineDiv);
+ expect(spanA.text).toBe('a');
+ expect(spanA.left).toBe(0);
+ expect(spanA.width).toBe(10); // 1 cell
- const spanCJK = lineDiv.children[1] as HTMLSpanElement;
- expect(spanCJK.textContent).toBe('你');
- expect(spanCJK.style.left).toBe('10px'); // col 1
- expect(spanCJK.style.width).toBe('20px'); // 2 cells
+ expect(spanCJK.text).toBe('你');
+ expect(spanCJK.left).toBe(10); // col 1
+ expect(spanCJK.width).toBe(20); // 2 cells
- const spanB = lineDiv.children[2] as HTMLSpanElement;
- expect(spanB.textContent).toBe('b');
- expect(spanB.style.left).toBe('30px'); // col 3
- expect(spanB.style.width).toBe('10px'); // 1 cell
+ expect(spanB.text).toBe('b');
+ expect(spanB.left).toBe(30); // col 3
+ expect(spanB.width).toBe(10); // 1 cell
});
it('cursor position accounts for CJK width', () => {
@@ -360,13 +391,11 @@ describe('renderOverlay', () => {
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
// h(col 0), i(col 1), 你(col 2, width 2)
- const spanH = lineDiv.children[0] as HTMLSpanElement;
- expect(spanH.style.left).toBe('0px');
- const spanI = lineDiv.children[1] as HTMLSpanElement;
- expect(spanI.style.left).toBe('8px');
- const spanCJK = lineDiv.children[2] as HTMLSpanElement;
- expect(spanCJK.style.left).toBe('16px');
- expect(spanCJK.style.width).toBe('16px');
+ const [spanH, spanI, spanCJK] = lineSpans(lineDiv);
+ expect(spanH.left).toBe(0);
+ expect(spanI.left).toBe(8);
+ expect(spanCJK.left).toBe(16);
+ expect(spanCJK.width).toBe(16);
});
});
@@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => {
expect(container.style.top).toBe(6 * 17 + 'px');
});
});
+
+describe('renderOverlay: rebuilding only the lines that changed', () => {
+ const lineEls = (container: HTMLDivElement) =>
+ Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
+ const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join('');
+
+ it('appending to the last line keeps every earlier line element', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] }));
+ const [first, second, third] = lineEls(container);
+
+ renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] }));
+ const after = lineEls(container);
+ expect(after[0]).toBe(first);
+ expect(after[1]).toBe(second);
+ expect(after[2]).not.toBe(third);
+ expect(after.map(text)).toEqual(['first', 'second', 'thi']);
+ });
+
+ it('keeps the cursor as the last child and moves it with the text', () => {
+ const container = document.createElement('div');
+ const params = { startCol: 0, cellW: 10 };
+ renderOverlay(container, makeParams({ ...params, lines: ['ab'] }));
+ renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] }));
+ const children = Array.from(container.children) as HTMLElement[];
+ expect(children.length).toBe(3);
+ const cursor = children[2];
+ expect(cursor.tagName).toBe('SPAN');
+ expect(cursor.style.left).toBe('10px');
+ expect(cursor.style.top).toBe(17 + 'px');
+ expect(container.querySelectorAll(':scope > span').length).toBe(1);
+ });
+
+ it('a wrapped line coming back (backspace) removes the extra line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abc', 'd'] }));
+ const [first] = lineEls(container);
+ renderOverlay(container, makeParams({ lines: ['abc'] }));
+ expect(lineEls(container)).toEqual([first]);
+ expect(container.children.length).toBe(2); // line + cursor
+ });
+
+ it('removes the cursor when the next render does not show one', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['ab'] }));
+ renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
+ expect(container.children.length).toBe(1);
+ expect(container.children[0].tagName).toBe('DIV');
+ });
+
+ it('a font, colour or cell size change rebuilds every line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ const before = lineEls(container);
+
+ renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } }));
+ const recoloured = lineEls(container);
+ expect(recoloured[0]).not.toBe(before[0]);
+ expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)');
+
+ renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 }));
+ const resized = lineEls(container);
+ expect(resized[1]).not.toBe(recoloured[1]);
+ expect(lineSpans(resized[1])[1].left).toBe(12);
+ });
+
+ it('a Unicode version switch rebuilds lines whose text did not change', () => {
+ const container = document.createElement('div');
+ const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) };
+ const terminal = { unicode } as any;
+ renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
+ const before = lineEls(container)[0];
+
+ unicode.activeVersion = '11';
+ unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1);
+ renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
+ const after = lineEls(container)[0];
+ expect(after).not.toBe(before);
+ expect(lineSpans(after)[1].width).toBe(20);
+ });
+
+ it('starts over when something else emptied the container', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ container.innerHTML = '';
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ expect(lineEls(container).map(text)).toEqual(['one', 'two']);
+ expect(container.children.length).toBe(3);
+ });
+
+ it('starts over when something else reordered the lines', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ const [one, two] = lineEls(container);
+ container.insertBefore(two, one);
+ renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
+ expect(lineEls(container).map(text)).toEqual(['one', 'two']);
+ expect(container.children.length).toBe(3);
+ });
+
+ it('starts over when something else added to the container', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['one'] }));
+ container.appendChild(document.createElement('div'));
+ renderOverlay(container, makeParams({ lines: ['one'] }));
+ expect(container.children.length).toBe(2);
+ expect(text(lineEls(container)[0])).toBe('one');
+ });
+
+ it('a composition growing on the last line leaves the lines it does not reach alone', () => {
+ const container = document.createElement('div');
+ // 'first' (5) + 'abc' pending, composition starts at code point 8.
+ renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 }));
+ const [first] = lineEls(container);
+ renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 }));
+ const after = lineEls(container);
+ expect(after[0]).toBe(first);
+ const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition'));
+ expect(marked.map((s) => s.textContent).join('')).toBe('xy');
+ });
+
+ it('a composition that starts on an earlier line marks the whole later line', () => {
+ const container = document.createElement('div');
+ renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 }));
+ const [first, second] = lineEls(container);
+ const marked = (line: HTMLDivElement) =>
+ Array.from(line.children)
+ .filter((s) => s.hasAttribute('data-zerolag-composition'))
+ .map((s) => s.textContent)
+ .join('');
+ expect(marked(first)).toBe('XY');
+ expect(marked(second)).toBe('ZW');
+ });
+
+ it('two containers keep separate records', () => {
+ const a = document.createElement('div');
+ const b = document.createElement('div');
+ renderOverlay(a, makeParams({ lines: ['same'] }));
+ renderOverlay(b, makeParams({ lines: ['same'] }));
+ expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]);
+ expect(text(lineEls(a)[0])).toBe('same');
+ expect(text(lineEls(b)[0])).toBe('same');
+ });
+});
diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
index e46538687..4718cb253 100644
--- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
+++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
@@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => {
expect(overlay.style.display).toBe('none');
});
});
+
+describe('setRowLimit', () => {
+ function rowLimitSetup(promptRow = 10) {
+ const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : ''));
+ const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 });
+ const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+ const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ return { addon, overlay };
+ }
+
+ it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => {
+ const { addon, overlay } = rowLimitSetup();
+ addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10
+ expect(overlay().style.top).toBe(`${10 * 17}px`);
+ addon.setRowLimit(12);
+ expect(overlay().style.top).toBe(`${9 * 17}px`);
+ });
+
+ it('null restores the full terminal height, and junk reads as null', () => {
+ const { addon, overlay } = rowLimitSetup();
+ addon.appendText('x'.repeat(50));
+ addon.setRowLimit(12);
+ addon.setRowLimit(null);
+ expect(overlay().style.top).toBe(`${10 * 17}px`);
+ addon.setRowLimit(12);
+ addon.setRowLimit(Number.NaN);
+ expect(overlay().style.top).toBe(`${10 * 17}px`);
+ });
+
+ // setRowLimit narrows the SAME bound renderOverlay already keeps the block on
+ // screen with (RenderParams.totalRows, filled from terminal.rows), so there is
+ // one place that decides where the block stops growing downward.
+ it('narrows the terminal-rows bound and never widens it', () => {
+ const { addon, overlay } = rowLimitSetup(22);
+ addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21
+ expect(overlay().style.top).toBe(`${21 * 17}px`);
+ addon.setRowLimit(24);
+ expect(overlay().style.top).toBe(`${21 * 17}px`);
+ addon.setRowLimit(100);
+ expect(overlay().style.top).toBe(`${21 * 17}px`);
+ addon.setRowLimit(23);
+ expect(overlay().style.top).toBe(`${20 * 17}px`);
+ });
+
+ it('leaves a block that fits above the limit where it is', () => {
+ const { addon, overlay } = rowLimitSetup();
+ addon.appendText('x'.repeat(50));
+ addon.setRowLimit(13); // rows 10..12 are exactly the three lines
+ expect(overlay().style.top).toBe(`${10 * 17}px`);
+ });
+
+ it('keeps the TAIL of a block taller than the limit', () => {
+ const { addon, overlay } = rowLimitSetup();
+ addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters
+ addon.setRowLimit(2);
+ expect(overlay().style.top).toBe('0px');
+ const xs = [...overlay().querySelectorAll(':scope > div')].map(
+ (line) => (line.textContent ?? '').replace(/[^x]/g, '').length
+ );
+ expect(xs).toEqual([20, 12]);
+ });
+
+ it('holds across later keystrokes, not only the re-render it triggers', () => {
+ const { addon, overlay } = rowLimitSetup();
+ addon.appendText('x'.repeat(50));
+ addon.setRowLimit(12);
+ addon.appendText('y');
+ expect(overlay().style.top).toBe(`${9 * 17}px`);
+ });
+});
diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json
index 8b0133e31..d300e12ca 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 only inside a Codeman-managed session.",
- "version": "1.45.0",
+ "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.46.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/plugins/codeman/README.md b/plugins/codeman/README.md
index 9ea8f05e8..06d502227 100644
--- a/plugins/codeman/README.md
+++ b/plugins/codeman/README.md
@@ -7,7 +7,7 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
/plugin install codeman@codeman
```
-The skill acts only inside a Codeman-managed session (`CODEMAN_MUX=1`) and refuses everywhere else, so installing it globally costs nothing for unrelated sessions.
+The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md
index 50133e8c9..755ade18e 100644
--- a/plugins/codeman/skills/codeman/SKILL.md
+++ b/plugins/codeman/skills/codeman/SKILL.md
@@ -8,12 +8,19 @@ description: >-
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
- another session, or start and manage workers. Only usable inside a Codeman-managed
- session (CODEMAN_MUX=1); refuse to act otherwise.
+ another session, start and manage workers, or retrieve saved local Codex history
+ by file/native ID or root and descendants with topic/date filters. Live API
+ operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
+ reads do not require a server or managed session.
---
# Driving Codeman from inside a session
+For **saved Codex conversation history**, use `codeman history read` and
+[reference/history.md](reference/history.md). This is a read-only local command:
+route to it **before §0**, including outside a managed session. It needs no API
+URL, credentials or running server. All live API operations below retain §0.
+
You are an agent running inside a Codeman-managed terminal session. Codeman is the
server that spawned you; its HTTP API can start, prompt, watch, and delete other
sessions.
@@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Experimental bounded inspection and guarded stage/submit load only when needed from
+[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
-That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox
-verbs `post`, `inbox`, `ack`) is the same
+That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
+`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
+For an existing project, use one command:
+`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
+no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
+positional case keeps quick-start behavior and cannot be combined with
+`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
+rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
+land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
+mark the project as agent scratch. If startup fails or is ambiguous, the CLI
+reports the exact created ID: inspect it before retrying or cleaning up.
+
## 0. Guard and bootstrap
-If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
-you are not part of is not yours to drive.
+For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
+Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md
new file mode 100644
index 000000000..ae087fb67
--- /dev/null
+++ b/plugins/codeman/skills/codeman/reference/history.md
@@ -0,0 +1,84 @@
+# Saved Codex history (offline)
+
+Use `codeman history read` for retained local Codex JSONL transcripts. This command
+works outside a managed session and never calls the HTTP API. Do not bootstrap
+API credentials or require `CODEMAN_MUX` for this read-only job.
+
+Choose exactly one selector:
+
+```bash
+codeman history read --file /path/to/rollout.jsonl
+codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
+codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
+ --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
+ --max-bytes 65536
+```
+
+`--session-id` accepts a native conversation UUID or unique prefix of at least
+8 characters; identity comes from metadata, not filenames. `--root-session-id`
+requires a full native UUID and includes transitive descendants found in metadata
+(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
+`~/.codex/sessions` when unset; a store directory that is itself a symlink is
+followed, links inside it are skipped. `--file` selects one file and does not
+search for its ancestors or children.
+
+Topic matching is a case-insensitive literal substring. Dates are inclusive
+`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
+user/assistant turns; a turn is retained if it has a date match and a topic match,
+which may be different messages in that turn. Tool calls/results and injected
+context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
+``) are omitted. Adjacent duplicate native representations
+are collapsed; repeated messages from the same representation are retained.
+
+A forked or spawned thread's file starts with a copy of its parent's history. That
+copy is dropped (it is in the parent's own file), so each record belongs to the
+thread that wrote it. A later `session_meta` for any thread that is not a declared
+ancestor still refuses the file.
+
+JSON is the default (`--json` or `--format json`); `--format text` prints one block
+per record. `--json` together with `--format text` is an error.
+Results identify source paths, native session/root IDs, timestamps, roles and phases,
+plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
+`final_answer`) on assistant records and `null` on user records or when none was
+recorded. A root ID is `null` when ancestry cannot be proven.
+Report that uncertainty; never relabel the queried child as its own root.
+`complete: false` reports malformed or partial records (a running Codex may be
+mid-write), skipped links, unattributed files, compressed rollouts, unreadable
+directories, missing ancestors, or an inherited copy whose end could not be found
+or proven (including when the parent's file is present but cannot be read). It
+does not prove the store includes every conversation ever created, and a filtered
+result is only evidence for its selected scope.
+
+Reads require regular UTF8 files and reject final-component symlinks, conflicting
+metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
+cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
+changing while its head is read) is counted in `unattributedFiles` instead of
+failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
+`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
+plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
+misses names them. A compressed file passed to `--file` is refused; read a
+decompressed copy instead (`zstd -dc > copy.jsonl`). A selected rollout
+that a running Codex is appending to is read as a snapshot up to its last complete
+line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
+not a filesystem sandbox.
+
+Budgets, each failing with an error rather than a partial answer. Every flag is
+validated before the first file is read, and only plain decimal integers are
+accepted:
+
+| Flag | Bounds | Default | Maximum |
+| ---- | ------ | ------- | ------- |
+| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
+| `--max-entries` | directory entries visited | 65536 | 1048576 |
+| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
+| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
+
+Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
+attributing a store costs about that much per rollout, charged to
+`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
+writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
+JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
+Missing evidence is an error, not an empty success.
+
+This command does not inspect live panes, stage prompts, submit input or manage
+sessions. For live operations return to the managed-session guard in the skill.
diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md
new file mode 100644
index 000000000..8f454748f
--- /dev/null
+++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md
@@ -0,0 +1,152 @@
+# Experimental bounded inspection and guarded submission
+
+**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
+`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
+are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
+payloads and behavior may change or be removed in a minor or patch release.
+Pin a compatible Codeman version when automating them. Authentication and
+session ownership checks still apply.
+
+They are also opt-in: until the user turns on **Experimental agent coordination**
+in App Settings (`agentCoordinationEnabled`, off by default), every one of them
+fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
+to retry or work around: tell the user, or use `send`/`read` instead.
+
+Use these commands inside a Codeman session. They use its authenticated HTTP API.
+They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
+tmux access is required.
+
+## Inspect recent evidence
+
+```bash
+codeman agent inspect "$SID" --max-bytes 8192 --json
+codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
+```
+
+Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
+interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
+metadata/envelope. The default is 8192, maximum 65536.
+
+- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
+ selects recent complete records within a bounded tail window;
+ `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
+ after the last consumed complete record. Partial final records remain pending.
+- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
+ output. It has `incremental:false` and `cursor:null`. This is the fallback for
+ other modes, remote/container sessions, ambiguous attribution, and exhausted
+ attribution budgets. Repeating a snapshot can repeat evidence.
+
+Attribution proves which file supplied the evidence, not which thread is now
+active in the TUI. A launch resume that conflicts with a newly owned rollout
+falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
+thread may leave launch metadata unchanged. Check `identity.conversationId`;
+these reads do not provide authoritative current-thread tracking.
+
+Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
+requesting identity, session, pane lifecycle generation
+and attributed transcript, expire after 15 minutes, and disappear on server
+restart. Each caller and session keeps its newest 8 (so retrying the last call
+works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
+or unavailable native source returns `409 CONFLICT`. Inspect again without
+`--since` to establish a new tail baseline; this may omit older evidence.
+
+`blockedReason` explains an unfinished or oversized record. Incremental reads
+never split or silently skip one. Increase the byte budget for
+`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
+use `read --tail` for a labeled terminal view or start a fresh tail baseline.
+Inspect scans at most 256 KiB of record data plus bounded generation anchors.
+Attribution separately caps directory entries (8192), depth (8), negative-filter
+prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
+activity are observations, not proof of task completion or authorization.
+
+## Stage, inspect, then submit once
+
+```bash
+printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
+codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
+# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
+codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
+```
+
+Stage types the file contents **without Enter**. The file must be regular, UTF8,
+one printable line, at most 8192 bytes, with no control characters or trailing
+whitespace/newline. It initially supports a local Codex composer with a recognized
+footer and an exact, unwrapped line/caret capture. Before typing, stage checks
+the actual captured width and composer indentation; a message that would reach
+the right edge or wrap is refused without writing anything. Unknown, working, modal,
+wrapped, remote/container and shell composers are refused. A customized footer or
+unsupported character-width rendering can also make verification unavailable.
+Self-targets are refused by the CLI.
+
+Stage also refuses, before typing, a message that would change what Enter does:
+one starting with `/` (a Codex slash command; with its command list open, Enter
+would run the highlighted command instead) or `!` (a shell command), and one whose
+**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
+follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
+is fine. Send slash and shell commands with `codeman agent send`, and end a
+message with an ordinary word when it mentions a file. A frame showing a
+suggestion popup above the composer is refused at stage and submit too.
+
+The receipt stores a hash and ephemeral identities, never prompt content. It
+expires after two minutes; capacity is 1024 live receipts. After typing, stage
+re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
+after the keys) and issues a receipt only for a frame that shows exactly the
+message with the caret after it. Any refusal after the keys went out (typing
+failed or was ambiguous, the pane or input changed, or no frame verified in time)
+says the message may already be in the composer: no receipt was issued. Do not
+stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
+
+While a submit's Enter is in flight (milliseconds, plus the moment it takes to
+write input held behind it), TAGGED input to that session (`clientId` and `seq`,
+which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
+with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
+and Codeman's own managed writes are held instead and written in arrival order
+once the Enter is out.
+
+Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
+geometry, exact composer and caret, then sends one conditional Enter at the session's input
+boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
+Enter that follows typed text within 120 ms into a newline), so submitting right
+after stage is fine. Competing Codeman writes are held during dispatch and written
+after it, except tagged input, which is refused with `409`. Browser/API/key
+input and managed restarts invalidate receipts even when the visible text or pid
+is restored. Direct tmux edits are outside that input serialization; captures
+remain evidence at the time observed, rather than terminal isolation.
+
+Every owned submit attempt consumes its receipt, including hash mismatch,
+verification failure and delivery failure. Concurrent/repeated submits cannot
+replay Enter, and there is no autonomous retry. A successful response means one
+Enter was delivered; use inspection or a suitable wait signal to observe native
+acceptance. A `409` or broken connection means inspect first. Do not automatically
+stage/submit again or send an unguarded Enter to recover.
+
+## Recover a stranded draft
+
+A consumed receipt cannot be reused, and a stage refused after typing issued
+none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
+(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
+accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
+do not clear or resend anything. A transcript prompt row alone is not proof
+that text remains unsent in the current composer.
+
+Only recover an unambiguously unsent draft in an input-ready local Codex
+composer: verify the complete draft, recognized footer and caret at its end in
+the current frame, and ensure no other writer can edit this target during
+recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
+it in the terminal instead of guessing keys. Never use `send` to overwrite a
+draft: that appends and submits both prompts together.
+
+After those checks, the known Codex line-clear chord may be sent once through
+the existing raw input route, with **no Enter**. This conditional example uses
+the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
+
+```bash
+"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
+ -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
+```
+
+Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
+verify the composer is empty and input-ready. Only then create a **fresh** stage
+receipt for the intended prompt and submit that receipt once. If the draft did
+not clear, the target began working, or verification remains uncertain, stop and
+ask the user; do not send an unguarded Enter or replay the old prompt.
diff --git a/plugins/codeman/skills/codeman/reference/messaging.md b/plugins/codeman/skills/codeman/reference/messaging.md
index 3b99f200e..99db822e7 100644
--- a/plugins/codeman/skills/codeman/reference/messaging.md
+++ b/plugins/codeman/skills/codeman/reference/messaging.md
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| Job | Channel |
| --- | --- |
-| spawn a worker, create its case | HTTP `quick-start` (the only path) |
+| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md
index 65af5c65f..d72d46bc7 100644
--- a/plugins/codeman/skills/codeman/reference/verbs.md
+++ b/plugins/codeman/skills/codeman/reference/verbs.md
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|-------------------|------|------------------------------|
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
-| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
+| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
Read `.data.casePath` back from the `quick-start` response and check it is where you
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
that half is the CLI's behavior, not something this API guarantees.
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
- typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
- with `{"input":"\r"}` and let the worker read the junk line.
+ typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
+ draft merely to clear it. For a refused guarded local Codex stage or submit, use the
+ verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
+ when the composer is unclear, ask the user to clear it in the terminal.
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
allowlist is S-Enter / C-Enter only.
@@ -811,3 +813,16 @@ Rules that matter:
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
+
+## Experimental bounded inspection and guarded submission
+
+These three commands and their HTTP routes are labeled experimental, so they may
+change or be removed in minor or patch releases. They are also opt-in
+(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
+it on). Use `codeman agent inspect` for bounded evidence and incremental local
+Codex cursors.
+Use `codeman agent stage --message-file` followed by one hash-confirmed
+`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
+composer and caret can be verified. Other targets provide labeled snapshots or
+refuse guarded input. See [inspection and staging](inspection-and-staging.md)
+for budgets, receipt expiry, conflict handling and supported composers.
diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md
index 50133e8c9..755ade18e 100644
--- a/skills/codeman/SKILL.md
+++ b/skills/codeman/SKILL.md
@@ -8,12 +8,19 @@ description: >-
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
- another session, or start and manage workers. Only usable inside a Codeman-managed
- session (CODEMAN_MUX=1); refuse to act otherwise.
+ another session, start and manage workers, or retrieve saved local Codex history
+ by file/native ID or root and descendants with topic/date filters. Live API
+ operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
+ reads do not require a server or managed session.
---
# Driving Codeman from inside a session
+For **saved Codex conversation history**, use `codeman history read` and
+[reference/history.md](reference/history.md). This is a read-only local command:
+route to it **before §0**, including outside a managed session. It needs no API
+URL, credentials or running server. All live API operations below retain §0.
+
You are an agent running inside a Codeman-managed terminal session. Codeman is the
server that spawned you; its HTTP API can start, prompt, watch, and delete other
sessions.
@@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Experimental bounded inspection and guarded stage/submit load only when needed from
+[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
-That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox
-verbs `post`, `inbox`, `ack`) is the same
+That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
+`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
no guessed URL) enforced in code, and it is also your own way through when this
machine has no `jq` for the preamble below.
+For an existing project, use one command:
+`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
+no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
+positional case keeps quick-start behavior and cannot be combined with
+`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
+rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
+land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
+mark the project as agent scratch. If startup fails or is ambiguous, the CLI
+reports the exact created ID: inspect it before retrying or cleaning up.
+
## 0. Guard and bootstrap
-If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
-you are not part of is not yours to drive.
+For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
+Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md
new file mode 100644
index 000000000..ae087fb67
--- /dev/null
+++ b/skills/codeman/reference/history.md
@@ -0,0 +1,84 @@
+# Saved Codex history (offline)
+
+Use `codeman history read` for retained local Codex JSONL transcripts. This command
+works outside a managed session and never calls the HTTP API. Do not bootstrap
+API credentials or require `CODEMAN_MUX` for this read-only job.
+
+Choose exactly one selector:
+
+```bash
+codeman history read --file /path/to/rollout.jsonl
+codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
+codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
+ --topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
+ --max-bytes 65536
+```
+
+`--session-id` accepts a native conversation UUID or unique prefix of at least
+8 characters; identity comes from metadata, not filenames. `--root-session-id`
+requires a full native UUID and includes transitive descendants found in metadata
+(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
+`~/.codex/sessions` when unset; a store directory that is itself a symlink is
+followed, links inside it are skipped. `--file` selects one file and does not
+search for its ancestors or children.
+
+Topic matching is a case-insensitive literal substring. Dates are inclusive
+`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
+user/assistant turns; a turn is retained if it has a date match and a topic match,
+which may be different messages in that turn. Tool calls/results and injected
+context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
+``) are omitted. Adjacent duplicate native representations
+are collapsed; repeated messages from the same representation are retained.
+
+A forked or spawned thread's file starts with a copy of its parent's history. That
+copy is dropped (it is in the parent's own file), so each record belongs to the
+thread that wrote it. A later `session_meta` for any thread that is not a declared
+ancestor still refuses the file.
+
+JSON is the default (`--json` or `--format json`); `--format text` prints one block
+per record. `--json` together with `--format text` is an error.
+Results identify source paths, native session/root IDs, timestamps, roles and phases,
+plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
+`final_answer`) on assistant records and `null` on user records or when none was
+recorded. A root ID is `null` when ancestry cannot be proven.
+Report that uncertainty; never relabel the queried child as its own root.
+`complete: false` reports malformed or partial records (a running Codex may be
+mid-write), skipped links, unattributed files, compressed rollouts, unreadable
+directories, missing ancestors, or an inherited copy whose end could not be found
+or proven (including when the parent's file is present but cannot be read). It
+does not prove the store includes every conversation ever created, and a filtered
+result is only evidence for its selected scope.
+
+Reads require regular UTF8 files and reject final-component symlinks, conflicting
+metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
+cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
+changing while its head is read) is counted in `unattributedFiles` instead of
+failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
+`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
+plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
+misses names them. A compressed file passed to `--file` is refused; read a
+decompressed copy instead (`zstd -dc > copy.jsonl`). A selected rollout
+that a running Codex is appending to is read as a snapshot up to its last complete
+line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
+not a filesystem sandbox.
+
+Budgets, each failing with an error rather than a partial answer. Every flag is
+validated before the first file is read, and only plain decimal integers are
+accepted:
+
+| Flag | Bounds | Default | Maximum |
+| ---- | ------ | ------- | ------- |
+| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
+| `--max-entries` | directory entries visited | 65536 | 1048576 |
+| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
+| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
+
+Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
+attributing a store costs about that much per rollout, charged to
+`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
+writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
+JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
+Missing evidence is an error, not an empty success.
+
+This command does not inspect live panes, stage prompts, submit input or manage
+sessions. For live operations return to the managed-session guard in the skill.
diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md
new file mode 100644
index 000000000..8f454748f
--- /dev/null
+++ b/skills/codeman/reference/inspection-and-staging.md
@@ -0,0 +1,152 @@
+# Experimental bounded inspection and guarded submission
+
+**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
+`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
+are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
+payloads and behavior may change or be removed in a minor or patch release.
+Pin a compatible Codeman version when automating them. Authentication and
+session ownership checks still apply.
+
+They are also opt-in: until the user turns on **Experimental agent coordination**
+in App Settings (`agentCoordinationEnabled`, off by default), every one of them
+fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
+to retry or work around: tell the user, or use `send`/`read` instead.
+
+Use these commands inside a Codeman session. They use its authenticated HTTP API.
+They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
+tmux access is required.
+
+## Inspect recent evidence
+
+```bash
+codeman agent inspect "$SID" --max-bytes 8192 --json
+codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
+```
+
+Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
+interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
+metadata/envelope. The default is 8192, maximum 65536.
+
+- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
+ selects recent complete records within a bounded tail window;
+ `omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
+ after the last consumed complete record. Partial final records remain pending.
+- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
+ output. It has `incremental:false` and `cursor:null`. This is the fallback for
+ other modes, remote/container sessions, ambiguous attribution, and exhausted
+ attribution budgets. Repeating a snapshot can repeat evidence.
+
+Attribution proves which file supplied the evidence, not which thread is now
+active in the TUI. A launch resume that conflicts with a newly owned rollout
+falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
+thread may leave launch metadata unchanged. Check `identity.conversationId`;
+these reads do not provide authoritative current-thread tracking.
+
+Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
+requesting identity, session, pane lifecycle generation
+and attributed transcript, expire after 15 minutes, and disappear on server
+restart. Each caller and session keeps its newest 8 (so retrying the last call
+works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
+or unavailable native source returns `409 CONFLICT`. Inspect again without
+`--since` to establish a new tail baseline; this may omit older evidence.
+
+`blockedReason` explains an unfinished or oversized record. Incremental reads
+never split or silently skip one. Increase the byte budget for
+`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
+use `read --tail` for a labeled terminal view or start a fresh tail baseline.
+Inspect scans at most 256 KiB of record data plus bounded generation anchors.
+Attribution separately caps directory entries (8192), depth (8), negative-filter
+prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
+activity are observations, not proof of task completion or authorization.
+
+## Stage, inspect, then submit once
+
+```bash
+printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
+codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
+# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
+codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
+```
+
+Stage types the file contents **without Enter**. The file must be regular, UTF8,
+one printable line, at most 8192 bytes, with no control characters or trailing
+whitespace/newline. It initially supports a local Codex composer with a recognized
+footer and an exact, unwrapped line/caret capture. Before typing, stage checks
+the actual captured width and composer indentation; a message that would reach
+the right edge or wrap is refused without writing anything. Unknown, working, modal,
+wrapped, remote/container and shell composers are refused. A customized footer or
+unsupported character-width rendering can also make verification unavailable.
+Self-targets are refused by the CLI.
+
+Stage also refuses, before typing, a message that would change what Enter does:
+one starting with `/` (a Codex slash command; with its command list open, Enter
+would run the highlighted command instead) or `!` (a shell command), and one whose
+**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
+follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
+is fine. Send slash and shell commands with `codeman agent send`, and end a
+message with an ordinary word when it mentions a file. A frame showing a
+suggestion popup above the composer is refused at stage and submit too.
+
+The receipt stores a hash and ephemeral identities, never prompt content. It
+expires after two minutes; capacity is 1024 live receipts. After typing, stage
+re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
+after the keys) and issues a receipt only for a frame that shows exactly the
+message with the caret after it. Any refusal after the keys went out (typing
+failed or was ambiguous, the pane or input changed, or no frame verified in time)
+says the message may already be in the composer: no receipt was issued. Do not
+stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
+
+While a submit's Enter is in flight (milliseconds, plus the moment it takes to
+write input held behind it), TAGGED input to that session (`clientId` and `seq`,
+which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
+with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
+and Codeman's own managed writes are held instead and written in arrival order
+once the Enter is out.
+
+Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
+geometry, exact composer and caret, then sends one conditional Enter at the session's input
+boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
+Enter that follows typed text within 120 ms into a newline), so submitting right
+after stage is fine. Competing Codeman writes are held during dispatch and written
+after it, except tagged input, which is refused with `409`. Browser/API/key
+input and managed restarts invalidate receipts even when the visible text or pid
+is restored. Direct tmux edits are outside that input serialization; captures
+remain evidence at the time observed, rather than terminal isolation.
+
+Every owned submit attempt consumes its receipt, including hash mismatch,
+verification failure and delivery failure. Concurrent/repeated submits cannot
+replay Enter, and there is no autonomous retry. A successful response means one
+Enter was delivered; use inspection or a suitable wait signal to observe native
+acceptance. A `409` or broken connection means inspect first. Do not automatically
+stage/submit again or send an unguarded Enter to recover.
+
+## Recover a stranded draft
+
+A consumed receipt cannot be reused, and a stage refused after typing issued
+none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
+(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
+accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
+do not clear or resend anything. A transcript prompt row alone is not proof
+that text remains unsent in the current composer.
+
+Only recover an unambiguously unsent draft in an input-ready local Codex
+composer: verify the complete draft, recognized footer and caret at its end in
+the current frame, and ensure no other writer can edit this target during
+recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
+it in the terminal instead of guessing keys. Never use `send` to overwrite a
+draft: that appends and submits both prompts together.
+
+After those checks, the known Codex line-clear chord may be sent once through
+the existing raw input route, with **no Enter**. This conditional example uses
+the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
+
+```bash
+"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
+ -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
+```
+
+Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
+verify the composer is empty and input-ready. Only then create a **fresh** stage
+receipt for the intended prompt and submit that receipt once. If the draft did
+not clear, the target began working, or verification remains uncertain, stop and
+ask the user; do not send an unguarded Enter or replay the old prompt.
diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md
index 3b99f200e..99db822e7 100644
--- a/skills/codeman/reference/messaging.md
+++ b/skills/codeman/reference/messaging.md
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| Job | Channel |
| --- | --- |
-| spawn a worker, create its case | HTTP `quick-start` (the only path) |
+| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md
index 65af5c65f..d72d46bc7 100644
--- a/skills/codeman/reference/verbs.md
+++ b/skills/codeman/reference/verbs.md
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|-------------------|------|------------------------------|
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
-| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
+| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
Read `.data.casePath` back from the `quick-start` response and check it is where you
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
that half is the CLI's behavior, not something this API guarantees.
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
- typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
- with `{"input":"\r"}` and let the worker read the junk line.
+ typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
+ draft merely to clear it. For a refused guarded local Codex stage or submit, use the
+ verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
+ when the composer is unclear, ask the user to clear it in the terminal.
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
allowlist is S-Enter / C-Enter only.
@@ -811,3 +813,16 @@ Rules that matter:
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
+
+## Experimental bounded inspection and guarded submission
+
+These three commands and their HTTP routes are labeled experimental, so they may
+change or be removed in minor or patch releases. They are also opt-in
+(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
+it on). Use `codeman agent inspect` for bounded evidence and incremental local
+Codex cursors.
+Use `codeman agent stage --message-file` followed by one hash-confirmed
+`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
+composer and caret can be verified. Other targets provide labeled snapshots or
+refuse guarded input. See [inspection and staging](inspection-and-staging.md)
+for budgets, receipt expiry, conflict handling and supported composers.
diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts
new file mode 100644
index 000000000..1ff2f280e
--- /dev/null
+++ b/src/agent-codex-identity.ts
@@ -0,0 +1,275 @@
+/**
+ * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
+ * newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
+ * The first call walks the store under explicit budgets; the attributed file is
+ * then remembered per session and only re-verified (plus a scan of the date
+ * directories not older than it) on later calls, since codex never prunes
+ * `~/.codex/sessions`.
+ */
+import { open, opendir, realpath } from 'node:fs/promises';
+import { probePathKind } from './utils/bounded-path-probe.js';
+import { tmpdir } from 'node:os';
+import { dirname, join, relative, resolve, sep } from 'node:path';
+import { LRUMap } from './utils/lru-map.js';
+
+const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
+const HEAD_BYTES = 131072;
+const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
+export interface CodexAttributionOptions {
+ codexHome: string;
+ sessionId: string;
+ workingDir: string;
+ resumeSessionId?: string;
+}
+export interface CodexAttributionBudget {
+ maxEntries: number;
+ maxBytes: number;
+ /** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */
+ maxPrefixBytes?: number;
+}
+interface Metadata {
+ id?: string;
+ cwd?: string;
+ originator?: string;
+}
+async function canonical(path: string): Promise {
+ if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
+ try {
+ return await realpath(path);
+ } catch {
+ return undefined;
+ }
+}
+/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */
+export function resolveEffectiveCodexHome(
+ sessionHome: string | undefined,
+ workingDir: string,
+ env: Readonly> = process.env
+): string {
+ const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
+ return path;
+}
+/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */
+function strictMetadata(bytes: Buffer): Metadata | undefined {
+ const rows = bytes.toString('utf8').split('\n');
+ rows.pop();
+ let found: Metadata | undefined;
+ for (const line of rows) {
+ try {
+ const row = JSON.parse(line) as { type?: string; payload?: Metadata };
+ if (row.type !== 'session_meta') continue;
+ if (
+ found ||
+ typeof row.payload?.id !== 'string' ||
+ typeof row.payload.cwd !== 'string' ||
+ (row.payload.originator !== undefined && typeof row.payload.originator !== 'string')
+ )
+ return undefined;
+ found = row.payload;
+ } catch {
+ /* Incomplete or malformed rows are not identity evidence. */
+ }
+ }
+ return found;
+}
+/**
+ * The rollout a previous call attributed, per session. A later call re-verifies only
+ * this file's head (same `dev:ino`, same strict metadata) and then scans just the
+ * directories not older than its own for a second candidate (codex `/new`, or a new
+ * owned rollout beside a configured resume), instead of re-reading the whole store,
+ * which codex never prunes. A head that no longer verifies drops the entry and falls
+ * back to the full walk; a failed incremental scan drops it and answers unattributed,
+ * since the full walk reads a superset of that scan. Bounded: one entry per live
+ * session is the expected size.
+ */
+interface AttributionMemo {
+ id: string;
+ path: string;
+ dev: number;
+ ino: number;
+ meta: Metadata;
+ /** Directory components of `path` below `sessions/`, used to prune older siblings. */
+ dirParts: string[];
+}
+const attributionMemo = new LRUMap({ maxSize: 1024 });
+/** Test-only: forget every remembered attribution. */
+export function __clearCodexAttributionMemo(): void {
+ attributionMemo.clear();
+}
+/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */
+const PREFIX_FIRST_BYTES = 1024;
+/** Fallback read when the first window holds no complete `originator` field. */
+const PREFIX_MAX_BYTES = 4096;
+const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/;
+/**
+ * Is a date-shaped directory name older than the remembered one at the same depth?
+ * Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else
+ * is never pruned, so an unexpected layout costs a wider scan, not a missed rollout.
+ */
+function olderDatePart(name: string, memo: string | undefined): boolean {
+ if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false;
+ return Number(name) < Number(memo);
+}
+/**
+ * A configured resume and a newly owned rollout conflict rather than pinning an
+ * old launch forever after /new. TUI resumes of foreign-originator threads cannot
+ * be authoritatively tracked from this metadata; this returns attributed evidence.
+ */
+export async function findExactCodexConversation(
+ options: CodexAttributionOptions,
+ budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 }
+): Promise<{ id: string; path: string } | undefined> {
+ const workspace = await canonical(options.workingDir);
+ const home = await canonical(options.codexHome);
+ if (!workspace || !home) return undefined;
+ const sessions = join(home, 'sessions');
+ if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined;
+ const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0');
+ const remembered = attributionMemo.get(key);
+ if (remembered) {
+ const verified = await verifyRemembered(remembered, workspace, budget);
+ if (verified) {
+ const others = await scanCandidates(sessions, options, budget, remembered.dirParts);
+ if (others && others.length === 1 && others[0].path === remembered.path) {
+ return { id: remembered.id, path: remembered.path };
+ }
+ }
+ attributionMemo.delete(key);
+ // A file that no longer verifies (replaced, rewritten, gone) gets one full walk
+ // now. A verified file whose incremental scan failed does not: that scan reads a
+ // subset of what a full walk reads under the same budget, so a second candidate,
+ // an exhausted budget or an unreadable entry it hit is one the full walk would hit
+ // too, and walking the whole never-pruned store could only end the same way.
+ if (verified) return undefined;
+ }
+ const candidates = await scanCandidates(sessions, options, budget);
+ if (!candidates || candidates.length !== 1) return undefined;
+ const candidate = candidates[0];
+ if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined;
+ const handle = await open(candidate.path, 'r').catch(() => undefined);
+ if (!handle) return undefined;
+ try {
+ const info = await handle.stat();
+ const bytes = Buffer.alloc(HEAD_BYTES);
+ const read = await handle.read(bytes, 0, bytes.length, 0);
+ const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
+ if (
+ !meta ||
+ meta.id !== candidate.id ||
+ meta.cwd !== candidate.meta.cwd ||
+ meta.originator !== candidate.meta.originator
+ )
+ return undefined;
+ attributionMemo.set(key, {
+ id: candidate.id,
+ path: candidate.path,
+ dev: info.dev,
+ ino: info.ino,
+ meta,
+ dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean),
+ });
+ return { id: candidate.id, path: candidate.path };
+ } finally {
+ await handle.close();
+ }
+}
+/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */
+async function verifyRemembered(
+ memo: AttributionMemo,
+ workspace: string,
+ budget: CodexAttributionBudget
+): Promise {
+ if (HEAD_BYTES > budget.maxBytes) return false;
+ if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false;
+ const handle = await open(memo.path, 'r').catch(() => undefined);
+ if (!handle) return false;
+ try {
+ const info = await handle.stat();
+ if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false;
+ const bytes = Buffer.alloc(HEAD_BYTES);
+ const read = await handle.read(bytes, 0, bytes.length, 0);
+ const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
+ return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator;
+ } catch {
+ return false;
+ } finally {
+ await handle.close();
+ }
+}
+/**
+ * Every rollout under `sessions` whose strict metadata names this session (or the
+ * configured resume id), or undefined when the walk hit a bound or an unreadable
+ * entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped
+ * directories older than those components are skipped.
+ */
+async function scanCandidates(
+ sessions: string,
+ options: CodexAttributionOptions,
+ budget: CodexAttributionBudget,
+ notOlderThan?: string[]
+): Promise | undefined> {
+ // `onBoundary`: every ancestor so far equals the remembered path, so pruning applies.
+ const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }];
+ const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
+ const originator = `codeman_${options.sessionId}`;
+ const originatorJson = JSON.stringify(originator);
+ const explicit = options.resumeSessionId;
+ let entries = 0;
+ let prefixes = 0;
+ const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES);
+ let scanned = HEAD_BYTES; // Reserve an independent final verification.
+ while (directories.length) {
+ const dir = directories.pop()!;
+ const handle = await opendir(dir.path).catch(() => undefined);
+ if (!handle) return undefined;
+ for await (const entry of handle) {
+ if (++entries > budget.maxEntries) return undefined;
+ const path = join(dir.path, entry.name);
+ if (entry.isDirectory()) {
+ if (dir.depth >= 8) return undefined;
+ const part = notOlderThan?.[dir.depth];
+ if (dir.onBoundary && olderDatePart(entry.name, part)) continue;
+ directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part });
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
+ const id = entry.name.match(UUID_AT_END)?.[1];
+ if (!id) continue; // no rollout id in the name: never a candidate, read nothing
+ const file = await open(path, 'r').catch(() => undefined);
+ if (!file) return undefined;
+ try {
+ const info = await file.stat();
+ if (!info.isFile()) return undefined;
+ if (id !== explicit) {
+ // Cheap negative filter only; a match still needs full parsed metadata.
+ // A short first window decides when it already holds the whole
+ // `originator` field; only otherwise is the wider window read.
+ let window = Math.min(info.size, PREFIX_FIRST_BYTES);
+ let prefix = '';
+ for (;;) {
+ if (prefixes + window > prefixLimit) return undefined;
+ prefixes += window;
+ const buffer = Buffer.alloc(window);
+ const first = await file.read(buffer, 0, window, 0);
+ prefix = buffer.subarray(0, first.bytesRead).toString('utf8');
+ if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break;
+ const wider = Math.min(info.size, PREFIX_MAX_BYTES);
+ if (wider <= window) break;
+ window = wider;
+ }
+ if (!prefix.includes(originatorJson)) continue;
+ }
+ const size = Math.min(info.size, HEAD_BYTES);
+ if (scanned + size > budget.maxBytes) return undefined;
+ scanned += size;
+ const bytes = Buffer.alloc(size);
+ const read = await file.read(bytes, 0, size, 0);
+ const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
+ if (!meta || meta.id !== id) continue;
+ if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
+ } finally {
+ await file.close();
+ }
+ }
+ }
+ }
+ return candidates;
+}
diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts
new file mode 100644
index 000000000..eec6c159c
--- /dev/null
+++ b/src/agent-inspection.ts
@@ -0,0 +1,208 @@
+/**
+ * @fileoverview Bounded append-only JSONL evidence reads for agent inspection.
+ * Opaque, expiring server-side cursors contain no public filesystem paths. A cursor
+ * binds the caller, pane generation, attributed conversation and opened file identity.
+ * Complete records only; an oversized row stalls explicitly rather than losing evidence.
+ */
+import { open, type FileHandle } from 'node:fs/promises';
+import { createHash, randomUUID } from 'node:crypto';
+import { isUtf8 } from 'node:buffer';
+
+export const INSPECT_MAX_BYTES = 65536;
+export const INSPECT_SCAN_BYTES = 262144;
+export interface InspectionScope {
+ owner: string;
+ sessionId: string;
+ generation: string;
+ conversationId: string;
+}
+interface CursorState {
+ scope: string;
+ /** Owner and session only: the unit the per-scope cap counts. */
+ holder: string;
+ path: string;
+ identity: string;
+ offset: number;
+ observedSize: number;
+ modifiedAt: number;
+ changedAt: number;
+ anchor: string;
+ expiresAt: number;
+}
+export class InspectionConflict extends Error {}
+
+/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */
+export function boundedUtf8Tail(text: string, maxBytes: number): string {
+ // A UTF8 suffix fits within this many UTF16 units. Include a pair of extra
+ // units so slicing cannot introduce a surrogate replacement inside the result.
+ const bytes = Buffer.from(text.slice(-maxBytes - 2));
+ let start = Math.max(0, bytes.length - maxBytes);
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
+ return bytes.subarray(start).toString('utf8');
+}
+async function anchor(handle: FileHandle, offset: number): Promise {
+ const count = Math.min(offset, 4096);
+ const head = Buffer.alloc(count);
+ const tail = Buffer.alloc(count);
+ await handle.read(head, 0, count, 0);
+ await handle.read(tail, 0, count, Math.max(0, offset - count));
+ return createHash('sha256').update(head).update(tail).digest('hex');
+}
+
+/** Cursors kept per owner and session; older ones are retired first. */
+export const INSPECT_CURSORS_PER_SCOPE = 8;
+
+export class InspectionReader {
+ private readonly cursors = new Map();
+ constructor(
+ private readonly now = Date.now,
+ private readonly capacity = 1024,
+ private readonly ttlMs = 900000,
+ private readonly perScope = INSPECT_CURSORS_PER_SCOPE
+ ) {}
+
+ async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) {
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES)
+ throw new Error('Invalid byte budget');
+ const scopeKey = JSON.stringify(scope);
+ const previous = since ? this.cursors.get(since) : undefined;
+ if (
+ since &&
+ (!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path)
+ ) {
+ throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again');
+ }
+ const handle = await open(path, 'r');
+ try {
+ const before = await handle.stat();
+ if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file');
+ const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`;
+ let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1);
+ if (
+ previous &&
+ (identity !== previous.identity ||
+ before.size < previous.observedSize ||
+ (before.size === previous.observedSize &&
+ (before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) ||
+ (await anchor(handle, offset)) !== previous.anchor)
+ ) {
+ throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again');
+ }
+ let startsAtBoundary = offset === 0;
+ if (!previous && offset > 0) {
+ const preceding = Buffer.alloc(1);
+ await handle.read(preceding, 0, 1, offset - 1);
+ startsAtBoundary = preceding[0] === 10;
+ }
+ const scan = Buffer.alloc(
+ Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset))
+ );
+ const { bytesRead } = await handle.read(scan, 0, scan.length, offset);
+ let selectedStart = 0;
+ let consumed = 0;
+ let blockedReason: string | undefined;
+ let omittedBeforeBytes = 0;
+ if (!previous) {
+ const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1;
+ const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1;
+ if (!startsAtBoundary && leading === 0) {
+ // The tail lies wholly inside one unfinished oversized record. Its
+ // beginning is not known: retain the known file boundary rather than
+ // manufacture a cursor pointing into a JSON/UTF8 fragment.
+ offset = 0;
+ blockedReason = 'record-exceeds-scan-limit';
+ } else {
+ selectedStart = completeEnd;
+ consumed = completeEnd;
+ while (selectedStart > leading) {
+ const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1);
+ if (completeEnd - recordStart > maxBytes) {
+ if (selectedStart === completeEnd) {
+ selectedStart = recordStart;
+ consumed = recordStart;
+ blockedReason = 'record-exceeds-output-budget';
+ }
+ break;
+ }
+ selectedStart = recordStart;
+ }
+ if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record';
+ // If the only known boundary is the end of a skipped leading row,
+ // the remainder is a partial record and stays pending there.
+ if (completeEnd < leading) selectedStart = consumed = leading;
+ omittedBeforeBytes = offset + selectedStart;
+ }
+ } else {
+ while (consumed < bytesRead) {
+ const end = scan.indexOf(10, consumed);
+ if (end < 0) {
+ blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record';
+ break;
+ }
+ if (end + 1 > maxBytes) {
+ if (consumed === 0) blockedReason = 'record-exceeds-output-budget';
+ break;
+ }
+ consumed = end + 1;
+ }
+ }
+ const evidenceBytes = scan.subarray(selectedStart, consumed);
+ if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8');
+ for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) {
+ try {
+ JSON.parse(row);
+ } catch {
+ throw new InspectionConflict('Inspection source contains invalid JSONL');
+ }
+ }
+ const after = await handle.stat();
+ if (
+ after.size < before.size ||
+ after.ino !== before.ino ||
+ (after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs))
+ )
+ throw new InspectionConflict('Inspection source changed during read');
+ const nextAnchor = await anchor(handle, offset + consumed);
+ const cursor = randomUUID();
+ // No await between capacity enforcement and insertion: concurrent reads
+ // must not all reserve the same remaining slot while hashing anchors.
+ // Each owner and session keeps only its newest cursors, so one agent polling in
+ // a loop retires its own oldest ones instead of pushing every other caller's out
+ // of the shared map. The cursor just used stays among the newest, so a retry of
+ // the last call still works. The global capacity is only a backstop.
+ const holder = JSON.stringify([scope.owner, scope.sessionId]);
+ const mine: string[] = [];
+ for (const [key, value] of this.cursors) {
+ if (value.expiresAt <= this.now()) this.cursors.delete(key);
+ else if (value.holder === holder) mine.push(key);
+ }
+ while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!);
+ while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!);
+ this.cursors.set(cursor, {
+ scope: scopeKey,
+ holder,
+ path,
+ identity,
+ offset: offset + consumed,
+ observedSize: before.size,
+ modifiedAt: before.mtimeMs,
+ changedAt: before.ctimeMs,
+ anchor: nextAnchor,
+ expiresAt: this.now() + this.ttlMs,
+ });
+ return {
+ evidence: evidenceBytes.toString('utf8'),
+ cursor,
+ omittedBeforeBytes,
+ truncated: omittedBeforeBytes > 0 || offset + consumed < before.size,
+ blockedReason,
+ scannedBytes: bytesRead,
+ };
+ } finally {
+ await handle.close();
+ }
+ }
+ clear(): void {
+ this.cursors.clear();
+ }
+}
diff --git a/src/agent-staging.ts b/src/agent-staging.ts
new file mode 100644
index 000000000..f79e12349
--- /dev/null
+++ b/src/agent-staging.ts
@@ -0,0 +1,356 @@
+/**
+ * @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is
+ * retained: only caller/session identity, lifecycle/input revisions and SHA-256.
+ * A receipt is consumed synchronously before verification/delivery, including failures.
+ */
+import { createHash, randomUUID } from 'node:crypto';
+import { stripAnsi } from './utils/regex-patterns.js';
+import { visibleWidth } from './tui/tui-ansi.js';
+import { compileVersionRegex } from './config/cli-registry/patterns.js';
+import type { CliEntry } from './config/cli-registry/types.js';
+
+/**
+ * The screen chrome a guarded composer read relies on, all from the CLI registry
+ * (`capabilities.workDetect`): the composer glyph, the pattern every row below an
+ * idle composer must match, and what opens a popup or turns Enter into a command.
+ * Nothing here names a CLI; a CLI takes part in guarded input by declaring
+ * `composerFooterLine`.
+ */
+export interface GuardedComposerChrome {
+ promptGlyph: string;
+ footerLine: RegExp;
+ /** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */
+ popupLine?: RegExp;
+ /** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */
+ commandPrefixes?: string;
+ /** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */
+ completionSigils?: string;
+}
+/** The chrome a registry entry declares, or null (no guarded input for that CLI). */
+export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null {
+ const detect = entry?.capabilities.workDetect;
+ if (!detect?.promptGlyph || !detect.composerFooterLine) return null;
+ // Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal.
+ const footerLine = compileVersionRegex(detect.composerFooterLine);
+ if (!footerLine) return null;
+ // A declared popup pattern the guard refuses is no chrome at all: dropping it would
+ // silently read a popup frame as an idle composer.
+ const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined;
+ if (popupLine === null) return null;
+ return {
+ promptGlyph: detect.promptGlyph,
+ footerLine,
+ ...(popupLine ? { popupLine } : {}),
+ ...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}),
+ ...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}),
+ };
+}
+/**
+ * Why a message cannot be staged into this composer, or null. Typing it would change
+ * what Enter does rather than what it sends: a draft starting with a command prefix
+ * (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a
+ * prompt, and a last word starting with a completion sigil (codex: `@` file and
+ * mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter
+ * would accept. Checked before anything is typed. Only the LAST word matters for the
+ * sigils because the caret is at the end of the message and the popup follows the
+ * caret; a sigil word earlier in the message opens nothing.
+ */
+export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null {
+ const first = message.trimStart().charAt(0);
+ if (first && chrome.commandPrefixes?.includes(first))
+ return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`;
+ const last = message.split(/\s/).at(-1)?.charAt(0);
+ if (last && chrome.completionSigils?.includes(last))
+ return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``;
+ return null;
+}
+/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */
+const POPUP_WINDOW_ROWS = 12;
+/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */
+const POPUP_GAP_ROWS = 3;
+const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const composerRow = (chrome: GuardedComposerChrome) =>
+ new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`);
+export const STAGE_MAX_BYTES = 8192;
+export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex');
+export interface GuardedInputSnapshot {
+ generation: string;
+ revision: number;
+ composerSha: string;
+ /** Actual captured pane width minus the rendered composer prefix (including indentation). */
+ availableColumns: number;
+}
+export interface GuardedInputTarget {
+ id: string;
+ /** The session's composer chrome, read before typing for what a message would trigger. */
+ chrome(): GuardedComposerChrome | null;
+ snapshot(): GuardedInputSnapshot | null;
+ write(text: string): Promise;
+ submit(expected: GuardedInputSnapshot): Promise;
+}
+interface Receipt extends GuardedInputSnapshot {
+ owner: string;
+ sessionId: string;
+ expiresAt: number;
+ sha256: string;
+ /** When the staged keys went out, for `SUBMIT_QUIET_MS`. */
+ typedAt: number;
+}
+export class GuardedInputConflict extends Error {}
+
+/**
+ * How long stage keeps reading the pane after typing for a frame that shows the
+ * message, and how often. A capture taken right after the keys land is often a
+ * frame from before them: codex repaints its composer 30 to 60 ms after input
+ * arrives (measured on a live 0.162.0 pane), so the first read can show the old,
+ * empty composer, or nothing provable at all. A second of 50 ms reads covers that
+ * many times over and still answers the caller quickly.
+ */
+export const STAGE_SETTLE_TIMEOUT_MS = 1000;
+export const STAGE_SETTLE_INTERVAL_MS = 50;
+/**
+ * The least time between the staged keys and the conditional Enter. Stage types its
+ * text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a
+ * paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather
+ * than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/
+ * paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers
+ * would land inside it and be told one Enter was delivered while codex only broke
+ * the line. Submit waits out the rest of this window first; a keystroke in that
+ * time still invalidates the receipt.
+ */
+export const SUBMIT_QUIET_MS = 250;
+/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */
+export interface StageSettleOptions {
+ timeoutMs?: number;
+ intervalMs?: number;
+ sleep?: (ms: number) => Promise;
+}
+/**
+ * Every refusal after the keys went out says so: stage cannot tell whether the text
+ * landed, so the caller must look before staging again (the recovery steps clear a
+ * stranded draft with Ctrl+U).
+ */
+export const MAY_BE_TYPED =
+ 'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again';
+
+/**
+ * Conservatively read a composer: exactly one visible, unwrapped row and a known
+ * footer, both as the registry describes them. Never remove internal spaces, infer
+ * an unknown shell prompt, or interpret dialog options as authority. An unsupported
+ * frame yields no proof.
+ */
+export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null {
+ const isComposer = composerRow(chrome);
+ const lines = stripAnsi(screen)
+ .split('\n')
+ .map((line) => line.trimEnd());
+ const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line));
+ const last = rows.at(-1);
+ if (!last) return null;
+ const below = lines.slice(last.index + 1).filter((line) => line.trim());
+ if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null;
+ if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null;
+ // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
+ const raw = screen.split('\n')[last.index];
+ let dim = false;
+ let visible = '';
+ // eslint-disable-next-line no-control-regex
+ for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) {
+ // eslint-disable-next-line no-control-regex
+ if (/^\x1b\[/.test(token)) {
+ const codes = token.slice(2, -1).split(';').map(Number);
+ for (let i = 0; i < codes.length; i++) {
+ // Extended color payloads may contain 0, 2 or 22; those are not SGR modes.
+ if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) {
+ i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0;
+ } else if (codes[i] === 0 || codes[i] === 22) dim = false;
+ else if (codes[i] === 2) dim = true;
+ }
+ } else if (!dim) visible += token;
+ }
+ // The glyph and the one space after it are the prefix, not input.
+ return stripAnsi(visible)
+ .trimStart()
+ .slice(chrome.promptGlyph.length + 1)
+ .trimEnd();
+}
+
+/**
+ * Does a suggestion popup sit directly above the composer row? A popup draws over
+ * the rows above the composer and leaves the footer below it alone, so the footer
+ * check alone reads a popup frame as an idle composer. Read the contiguous block of
+ * rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane:
+ * the transcript further up is the agent's own and may print anything. A transcript
+ * row that happens to match costs a refusal, never a receipt.
+ */
+function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean {
+ let index = composerIndex - 1;
+ for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--;
+ for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) {
+ if (popupLine.test(lines[index])) return true;
+ }
+ return false;
+}
+
+/** Require the capture's caret at the exact end of the unwrapped composer. */
+export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null {
+ return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null;
+}
+/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
+export function readGuardedSnapshotDetails(
+ snapshot: string,
+ chrome: GuardedComposerChrome
+): { composer: string; prefixWidth: number } | null {
+ // formatPaneSnapshot emits one absolute row address per line, then the caret.
+ // Plain captures lack caret evidence and cannot prove invisible trailing edits.
+ // eslint-disable-next-line no-control-regex
+ const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)];
+ if (positions.length < 2) return null;
+ const caret = positions.at(-1)!;
+ if (caret.index! + caret[0].length !== snapshot.length) return null;
+ const rows: string[] = [];
+ for (let i = 0; i < positions.length - 1; i++) {
+ const position = positions[i];
+ if (Number(position[1]) !== i + 1 || position[2] !== '1') return null;
+ rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index));
+ }
+ const composer = readGuardedComposer(rows.join('\n'), chrome);
+ if (composer === null) return null;
+ const isComposer = composerRow(chrome);
+ let row = -1;
+ for (let i = rows.length - 1; i >= 0; i--)
+ if (isComposer.test(stripAnsi(rows[i]))) {
+ row = i;
+ break;
+ }
+ const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`));
+ if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1)
+ return null;
+ return { composer, prefixWidth: visibleWidth(prefix[1]) };
+}
+
+export class GuardedStageStore {
+ private readonly receipts = new Map();
+ private readonly busy = new Set();
+ private readonly settleTimeoutMs: number;
+ private readonly settleIntervalMs: number;
+ private readonly sleep: (ms: number) => Promise;
+ constructor(
+ private readonly now = Date.now,
+ private readonly capacity = 1024,
+ private readonly ttlMs = 120000,
+ settle: StageSettleOptions = {}
+ ) {
+ this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS;
+ this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS;
+ this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ }
+ async stage(target: GuardedInputTarget, owner: string, message: string) {
+ // eslint-disable-next-line no-control-regex
+ const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message);
+ if (
+ !message ||
+ message !== message.trimEnd() ||
+ Buffer.byteLength(message) > STAGE_MAX_BYTES ||
+ Buffer.from(message).toString('utf8') !== message ||
+ hasControls
+ )
+ throw new GuardedInputConflict(
+ 'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace'
+ );
+ const chrome = target.chrome();
+ const refusal = chrome && composerTriggerRefusal(message, chrome);
+ if (refusal) throw new GuardedInputConflict(refusal);
+ const key = target.id;
+ if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress');
+ this.busy.add(key);
+ try {
+ const before = target.snapshot();
+ if (!before || before.composerSha !== promptSha256(''))
+ throw new GuardedInputConflict('An empty, verifiable composer is required');
+ if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns)
+ throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane');
+ for (const [id, receipt] of this.receipts)
+ if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id);
+ // Refuse capacity before typing. Do not evict a caller's still-live receipt.
+ // Include in-flight stages (this operation already owns one busy slot),
+ // so different sessions cannot type concurrently into the same last slot.
+ if (this.receipts.size + this.busy.size > this.capacity)
+ throw new GuardedInputConflict('Guarded stage receipt capacity reached');
+ if (!(await target.write(message)))
+ throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`);
+ const typedAt = this.now();
+ const after = await this.settle(target, before, promptSha256(message));
+ const stageId = randomUUID();
+ const expiresAt = this.now() + this.ttlMs;
+ const sha256 = promptSha256(message);
+ this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt });
+ return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const };
+ } finally {
+ this.busy.delete(key);
+ }
+ }
+ /**
+ * The first frame after the write that shows exactly this message, with the input
+ * revision the write itself moved and nothing else changed. A null or stale frame
+ * (the old composer, a half-painted one) is read again until the settle window
+ * ends; a frame showing another writer's keystroke, a new pane lifecycle or a new
+ * geometry is refused at once, since waiting cannot undo that.
+ */
+ private async settle(
+ target: GuardedInputTarget,
+ before: GuardedInputSnapshot,
+ expectedSha: string
+ ): Promise {
+ const deadline = this.now() + this.settleTimeoutMs;
+ for (;;) {
+ const frame = target.snapshot();
+ if (
+ frame &&
+ (frame.generation !== before.generation ||
+ frame.revision !== before.revision + 1 ||
+ frame.availableColumns !== before.availableColumns)
+ )
+ throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`);
+ if (frame?.composerSha === expectedSha) return frame;
+ if (this.now() >= deadline)
+ throw new GuardedInputConflict(
+ `The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}`
+ );
+ await this.sleep(this.settleIntervalMs);
+ }
+ }
+ async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) {
+ const receipt = this.receipts.get(stageId);
+ if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id)
+ throw new GuardedInputConflict('Unknown, used, or foreign stage receipt');
+ // One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter.
+ this.receipts.delete(stageId);
+ if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress');
+ this.busy.add(target.id);
+ try {
+ if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha)
+ throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch');
+ const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now();
+ if (quiet > 0) await this.sleep(quiet);
+ const current = target.snapshot();
+ if (
+ !current ||
+ current.generation !== receipt.generation ||
+ current.revision !== receipt.revision ||
+ current.availableColumns !== receipt.availableColumns ||
+ current.composerSha !== receipt.sha256
+ )
+ throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted');
+ if (!(await target.submit(current)))
+ throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane');
+ return { stageId, sessionId: target.id, submitted: true as const };
+ } finally {
+ this.busy.delete(target.id);
+ }
+ }
+ clear(): void {
+ this.receipts.clear();
+ this.busy.clear();
+ }
+}
diff --git a/src/cli-agent.ts b/src/cli-agent.ts
index 6a5fad476..bc8625be0 100644
--- a/src/cli-agent.ts
+++ b/src/cli-agent.ts
@@ -2,16 +2,21 @@
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
- * A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
- * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the
- * agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It
- * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
- * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
+ * A thin HTTP client over the server's endpoints (`quick-start` for a named
+ * case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an
+ * existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`,
+ * `DELETE sessions/:id`, the agent inbox: `inbox`, `inbox/ack`,
+ * `agent-inbox/summary`, and the experimental, opt-in
+ * `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403
+ * until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no
+ * transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership
+ * and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
- * Invariants (each asserted in `test/cli-agent.test.ts`):
+ * Invariants (each asserted in `test/cli-agent.test.ts` or
+ * `test/cli-agent-project-spawn.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
@@ -23,14 +28,26 @@
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
+ * 5. `spawn` without a case starts the worker in an existing directory (cwd, or
+ * `--working-dir`) via `POST sessions` + `interactive|shell`, without the
+ * `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses
+ * (exit 4, before any request) an empty `--working-dir` and a bare spawn from a
+ * HOME cwd: it always sends an explicit `workingDir`, so the server's skip of
+ * workspace hooks for a request that names none cannot protect `~/.claude`.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
+import { open, realpath, stat } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { resolve } from 'node:path';
import http from 'node:http';
import https from 'node:https';
+import { isUtf8 } from 'node:buffer';
+import { STAGE_MAX_BYTES } from './agent-staging.js';
+import { INSPECT_MAX_BYTES } from './agent-inspection.js';
import type { Command } from 'commander';
import {
basicAuthHeader,
@@ -343,6 +360,18 @@ export const httpRequest: ApiRequest = (ctx, options) => {
});
};
+/**
+ * One line describing a thrown request error. Node 22 rejects a refused connection to a host
+ * that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so
+ * fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing.
+ */
+export function describeRequestError(error: unknown): string {
+ const message = getErrorMessage(error);
+ if (message) return message;
+ const code = (error as { code?: unknown } | null | undefined)?.code;
+ return code ? String(code) : 'request failed without a message';
+}
+
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
export function describeFailure(res: ApiResponse): string {
if (res.json && !res.json.success) {
@@ -372,7 +401,13 @@ export interface AgentDeps {
now?: () => number;
}
-/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
+/**
+ * Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy,
+ * except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus
+ * `ready` when a readiness wait ran, `readinessError` when that call failed, and
+ * `startupError` when its start fails, because `POST /api/sessions` answers with the
+ * whole session row.
+ */
function emitJson(deps: AgentDeps, data: unknown): void {
deps.io.out(JSON.stringify(data, null, 2));
}
@@ -509,7 +544,8 @@ export async function agentLs(deps: AgentDeps): Promise {
}
export interface SpawnOptions {
- caseName: string;
+ caseName?: string;
+ workingDir?: string;
mode: string;
name?: string;
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
@@ -532,44 +568,135 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl
export function composerReadyMark(mode: string): string | undefined {
return getCli(mode)?.capabilities.composerReadyMark;
}
-/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
+
+/** realpath, or the plain resolved path when it cannot be resolved; never throws. */
+async function canonicalPath(path: string): Promise {
+ try {
+ return await realpath(path);
+ } catch {
+ return resolve(path);
+ }
+}
+
+/**
+ * Whether `directory` is the user's home directory, comparing realpaths so a symlinked
+ * HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown.
+ */
+export async function isHomeDirectory(directory: string): Promise {
+ let home: string;
+ try {
+ home = homedir();
+ } catch {
+ return false;
+ }
+ if (!home) return false;
+ const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]);
+ return a === b;
+}
+
+/** `agent spawn`: start in a project directory or named case, preserving lineage and mode readiness. */
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise {
+ const cli = getCli(options.mode);
+ if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused);
+ if (options.caseName !== undefined && options.workingDir !== undefined)
+ return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused);
+ // Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed
+ // `git worktree add`) must not fall through to the caller's own checkout, which
+ // `resolve('')` would make it.
+ if (options.workingDir !== undefined && options.workingDir.trim() === '')
+ return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused);
+ // Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with
+ // CASE unset) still goes to quick-start, whose schema refuses it.
+ const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd());
+ if (workingDir) {
+ // eslint-disable-next-line no-control-regex
+ if (/[\x00-\x1f\x7f]/.test(workingDir))
+ return fail(deps, 'Working directory contains control characters', EXIT.refused);
+ try {
+ if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory');
+ } catch {
+ return fail(deps, 'Working directory must be an existing local directory', EXIT.refused);
+ }
+ // The server installs workspace hooks (and, with agent skill injection on, the
+ // skill) into the directory it is given, and it skips that only when the caller
+ // names no workingDir, because the cwd fallback is $HOME under installer-created
+ // services. This CLI always sends its directory explicitly, so an implicit cwd of
+ // HOME must be refused here or it would write ~/.claude/settings.local.json and
+ // ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays.
+ if (options.workingDir === undefined && (await isHomeDirectory(workingDir)))
+ return fail(
+ deps,
+ 'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose',
+ EXIT.refused
+ );
+ }
const body: Record = {
- caseName: options.caseName,
+ ...(workingDir ? { workingDir } : { caseName: options.caseName }),
mode: options.mode,
parentSessionId: deps.ctx.selfId,
};
- if (options.name) body.sessionName = options.name;
+ if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name;
const res = await deps.request(deps.ctx, {
method: 'POST',
- path: '/api/v1/quick-start',
+ path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start',
body,
- headers: { ...AGENT_ORIGIN_HEADER },
+ headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER },
});
- const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
+ const created = res.json?.data as
+ | { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string }
+ | undefined;
+ // `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`.
+ const createdId = created?.session?.id;
+ const data:
+ | { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean }
+ | undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created;
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
const sid = data.sessionId;
+ if (workingDir) {
+ // Creation alone leaves a session without a pane. Report the exact created id
+ // on an ambiguous or rejected start; never retry a start or hide a leaked tab.
+ let startupError: string | undefined;
+ try {
+ const started = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`,
+ });
+ if (!started.json?.success) startupError = describeFailure(started);
+ } catch (error) {
+ startupError = getErrorMessage(error) || 'Startup request failed';
+ }
+ if (startupError) {
+ if (deps.json) emitJson(deps, { ...data, ready: false, startupError });
+ else deps.io.out(sid);
+ deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`);
+ return EXIT.error;
+ }
+ }
let ready: boolean | undefined;
let readinessError: string | undefined;
let dead = false;
const mark = composerReadyMark(options.mode);
if (options.ready && mark) {
- const wait = await deps.request(deps.ctx, {
- method: 'GET',
- path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
- query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
- timeoutMs: options.timeoutMs + 10_000,
- });
- // A failed readiness call (waiter cap, 400, network) is its own error, not "the
- // composer never showed up": report the real reason instead of the trust-dialog hint.
- if (!wait.json?.success) readinessError = describeFailure(wait);
- else {
- const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
- // A worker that died while we waited is exit 3 like every other wait, not a
- // "composer not seen" timeout that sends the caller looking for a dialog.
- dead = waitExitCode(result) === EXIT.dead;
- ready = !dead && Boolean(result?.matched);
+ try {
+ const wait = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
+ query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
+ timeoutMs: options.timeoutMs + 10_000,
+ });
+ // A failed readiness call (waiter cap, 400, network) is its own error, not "the
+ // composer never showed up": report the real reason instead of the trust-dialog hint.
+ if (!wait.json?.success) readinessError = describeFailure(wait);
+ else {
+ const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
+ // A worker that died while we waited is exit 3 like every other wait, not a
+ // "composer not seen" timeout that sends the caller looking for a dialog.
+ dead = waitExitCode(result) === EXIT.dead;
+ ready = !dead && Boolean(result?.matched);
+ }
+ } catch (error) {
+ readinessError = describeRequestError(error);
}
}
@@ -578,7 +705,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis
} else {
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
const say = (line: string) => deps.io.err(line);
- say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
+ say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`));
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
if (ready === false && !dead) {
@@ -757,6 +884,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
return waitExitCode(data?.wait);
}
+/** Bounded incremental evidence, with an opaque server-owned cursor. */
+export async function agentInspect(
+ deps: AgentDeps,
+ options: { id: string; maxBytes: number; since?: string }
+): Promise {
+ if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES)
+ return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const response = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`,
+ query: { maxBytes: options.maxBytes, since: options.since },
+ });
+ if (!response.json?.success) return fail(deps, describeFailure(response));
+ if (deps.json) emitJson(deps, response.json.data);
+ else {
+ const data = response.json.data as {
+ source?: string;
+ evidence?: string;
+ truncated?: boolean;
+ cursor?: string;
+ blockedReason?: string;
+ };
+ deps.io.out(
+ `[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}`
+ );
+ deps.io.out(data.evidence ?? '');
+ if (data.cursor) deps.io.out(`cursor: ${data.cursor}`);
+ }
+ return EXIT.ok;
+}
+
+/** Stage exactly the printable UTF8 file contents; never sends Enter. */
+export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
+ let message: string;
+ try {
+ const info = await stat(options.messageFile);
+ if (!info.isFile() || info.size > STAGE_MAX_BYTES)
+ return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused);
+ const handle = await open(options.messageFile, 'r');
+ try {
+ const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1);
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
+ const bytes = buffer.subarray(0, bytesRead);
+ if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes))
+ return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused);
+ message = bytes.toString('utf8');
+ } finally {
+ await handle.close();
+ }
+ } catch (error) {
+ return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused);
+ }
+ const refusal = inputRefusal(message);
+ if (refusal || message !== message.trimEnd())
+ return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const response = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`,
+ body: { message },
+ });
+ if (!response.json?.success) return fail(deps, describeFailure(response));
+ if (deps.json) emitJson(deps, response.json.data);
+ else {
+ const data = response.json.data as { stageId: string; sha256: string; expiresAt: number };
+ deps.io.out(
+ `staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}`
+ );
+ }
+ return EXIT.ok;
+}
+
+/** Submit one verified staged composer; failure never automatically replays Enter. */
+export async function agentSubmit(
+ deps: AgentDeps,
+ options: { id: string; stageId: string; confirmSha: string }
+): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
+ if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId))
+ return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error, target.code);
+ const response = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`,
+ body: { stageId: options.stageId, confirmSha: options.confirmSha },
+ });
+ if (!response.json?.success) return fail(deps, describeFailure(response));
+ if (deps.json) emitJson(deps, response.json.data);
+ else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).');
+ return EXIT.ok;
+}
+
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
@@ -1038,7 +1262,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise): P
try {
process.exitCode = await verb(deps);
} catch (err) {
- console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
+ console.error(palette.err(`${GLYPH.fail} ${describeRequestError(err)}`));
process.exitCode = EXIT.error;
}
}
@@ -1048,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description(
- 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack'
+ 'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit'
);
agent
@@ -1059,20 +1283,26 @@ export function registerAgentCommands(program: Command): Command {
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
agent
- .command('spawn ')
- .description(
- 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
- )
+ .command('spawn [case]')
+ .description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported')
.option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude')
.option('-n, --name ', 'Session name shown in the UI')
+ .option(
+ '--working-dir ',
+ 'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)'
+ )
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
.option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
- (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
+ (
+ caseName: string | undefined,
+ options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean }
+ ) =>
run(Boolean(options.json), (deps) =>
agentSpawn(deps, {
caseName,
+ workingDir: options.workingDir,
mode: options.mode,
name: options.name,
ready: options.ready,
@@ -1169,6 +1399,49 @@ export function registerAgentCommands(program: Command): Command {
)
);
+ agent
+ .command('inspect ')
+ .description(
+ 'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors'
+ )
+ .option('--max-bytes ', 'UTF8 evidence budget (1–65536)', '8192')
+ .option('--since ', 'Resume a cursor from the same owner, pane and transcript')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentInspect(deps, {
+ id,
+ maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'),
+ since: options.since,
+ })
+ )
+ );
+
+ agent
+ .command('stage ')
+ .description(
+ 'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)'
+ )
+ .requiredOption('--message-file ', 'Regular UTF8 file, at most 8192 bytes, no trailing newline')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { messageFile: string; json?: boolean }) =>
+ run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
+ );
+
+ agent
+ .command('submit ')
+ .description(
+ 'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match'
+ )
+ .requiredOption('--stage-id ', 'One-shot stage receipt')
+ .requiredOption('--confirm-sha ', 'Exact SHA-256 returned by stage')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha })
+ )
+ );
+
agent
.command('read ')
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
diff --git a/src/cli-history.ts b/src/cli-history.ts
new file mode 100644
index 000000000..238224a2d
--- /dev/null
+++ b/src/cli-history.ts
@@ -0,0 +1,109 @@
+/**
+ * @fileoverview `codeman history read`: the command-line front of the offline
+ * Codex history reader (`local-history.ts`).
+ *
+ * Deliberately separate from `codeman agent` (`cli-agent.ts`): that command is
+ * a client of a running Codeman and refuses outside a managed session, while
+ * this one reads rollout files from disk and needs neither a server nor
+ * `CODEMAN_MUX`. It never writes, never inspects a live pane and never sends
+ * input.
+ *
+ * Every bound the reader enforces has a flag here with the reader's own
+ * maximum, so a large store is a matter of raising a number rather than an
+ * impossible lookup. Output is JSON unless `--format text` is given; `--json`
+ * is the JSON spelling and refuses to combine with `--format text`.
+ *
+ * @module cli-history
+ */
+import type { Command } from 'commander';
+import {
+ formatLocalHistory,
+ HISTORY_DEFAULT_MAX_ENTRIES,
+ HISTORY_DEFAULT_METADATA_BYTES,
+ HISTORY_DEFAULT_OUTPUT_BYTES,
+ HISTORY_DEFAULT_SCAN_BYTES,
+ HISTORY_MAX_ENTRIES,
+ HISTORY_MAX_METADATA_BYTES,
+ HISTORY_MAX_OUTPUT_BYTES,
+ HISTORY_MAX_SCAN_BYTES,
+ readLocalHistory,
+ validateOutputBytes,
+} from './local-history.js';
+
+/**
+ * A budget flag's value as a number. Only plain decimal digits count, so `0x10`,
+ * `1e3` and ` 5 ` are refused rather than read as 16, 1000 and 5; anything else
+ * becomes NaN, which the reader's range check rejects with the flag's name.
+ */
+function budgetFlag(value: string | boolean | undefined): number {
+ return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
+}
+
+export function registerHistoryCommands(program: Command): Command {
+ const history = program
+ .command('history')
+ .description('Read local saved native transcripts without a running server');
+ history
+ .command('read')
+ .description('Read Codex history by file, native id, or root conversation and descendants')
+ .option('--file ', 'Explicit local JSONL transcript (regular file only)')
+ .option('--session-id ', 'Native Codex conversation UUID or unique prefix (at least 8 characters)')
+ .option('--root-session-id ', 'Native root UUID, including transitive child conversations')
+ .option(
+ '--sessions-root ',
+ 'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)'
+ )
+ .option('--topic ', 'Case-insensitive literal match, preserving whole matching turns')
+ .option('--after ', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns')
+ .option('--before ', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns')
+ .option(
+ '--max-bytes ',
+ `Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`,
+ String(HISTORY_DEFAULT_OUTPUT_BYTES)
+ )
+ .option(
+ '--scan-bytes ',
+ `Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`,
+ String(HISTORY_DEFAULT_SCAN_BYTES)
+ )
+ .option(
+ '--metadata-bytes ',
+ `Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`,
+ String(HISTORY_DEFAULT_METADATA_BYTES)
+ )
+ .option(
+ '--max-entries ',
+ `Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`,
+ String(HISTORY_DEFAULT_MAX_ENTRIES)
+ )
+ .option('--format ', 'json or text', 'json')
+ .option('--json', 'JSON output (the default; conflicts with --format text)')
+ .action(async (options: Record, command: Command) => {
+ try {
+ if (options.format !== 'json' && options.format !== 'text') throw new Error('--format must be json or text');
+ if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli')
+ throw new Error('--json conflicts with --format text; pass one of them');
+ const format = options.format;
+ // Every flag is checked before the first file is read: --max-bytes would otherwise
+ // only be judged after the whole store had been scanned.
+ const maxBytes = budgetFlag(options.maxBytes);
+ validateOutputBytes(maxBytes);
+ const result = await readLocalHistory({
+ file: options.file as string | undefined,
+ sessionId: options.sessionId as string | undefined,
+ rootSessionId: options.rootSessionId as string | undefined,
+ sessionsRoot: options.sessionsRoot as string | undefined,
+ topic: options.topic as string | undefined,
+ after: options.after as string | undefined,
+ before: options.before as string | undefined,
+ scanBytes: budgetFlag(options.scanBytes),
+ metadataBytes: budgetFlag(options.metadataBytes),
+ maxEntries: budgetFlag(options.maxEntries),
+ });
+ program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`);
+ } catch (error) {
+ program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' });
+ }
+ });
+ return history;
+}
diff --git a/src/cli.ts b/src/cli.ts
index ba07793b3..4682aa895 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -29,6 +29,7 @@ import {
import type { AgentSkillDir } from './config/cli-registry/types.js';
import { getCli, listClis } from './config/cli-registry/registry.js';
import { registerAgentCommands } from './cli-agent.js';
+import { registerHistoryCommands } from './cli-history.js';
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -324,6 +325,7 @@ skillCmd
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
+registerHistoryCommands(program);
// ============ MCP server (any MCP client drives Codeman) ============
@@ -950,6 +952,10 @@ function addWebLaunchOptions(cmd: Command): Command {
'--allow-unauthenticated-network',
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
)
+ .option(
+ '--allow-unauthenticated-tunnel',
+ 'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)'
+ )
.option(
'--multiuser',
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
@@ -964,6 +970,7 @@ function toWebLaunchOptions(options: {
baseUrl?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
+ allowUnauthenticatedTunnel?: boolean;
multiuser?: boolean;
}): WebLaunchOptions {
const port = parseInt(options.port, 10);
@@ -985,6 +992,7 @@ function toWebLaunchOptions(options: {
basePath,
titleHostname: options.titleHostname,
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
+ allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel,
multiuser: !!options.multiuser,
};
}
@@ -1072,6 +1080,7 @@ webCmd.action(async (options) => {
const https = launch.https;
const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
+ const allowUnauthenticatedTunnel = launch.allowUnauthenticatedTunnel ?? false;
const basePath = launch.basePath ?? '';
// Single source of truth for subsystems that read it directly (e.g. renderers).
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
@@ -1086,7 +1095,16 @@ webCmd.action(async (options) => {
try {
// The server prints its own "running at" line (it also covers the daemon and
// service launch paths), so this one used to be a duplicate of it.
- const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
+ const server = await startWebServer(
+ port,
+ https,
+ false,
+ host,
+ titleHostname,
+ allowUnauthenticatedNetwork,
+ basePath,
+ allowUnauthenticatedTunnel
+ );
if (https) {
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
}
diff --git a/src/codex-rollout-format.ts b/src/codex-rollout-format.ts
new file mode 100644
index 000000000..068e68bf9
--- /dev/null
+++ b/src/codex-rollout-format.ts
@@ -0,0 +1,128 @@
+/**
+ * @fileoverview Shape helpers for Codex rollout JSONL lines, shared by the two
+ * readers of `~/.codex/sessions`: the Past Sessions scanner
+ * (`codex-transcript.ts`, previews only) and the offline history reader
+ * (`local-history.ts`, whole conversations).
+ *
+ * Both readers must agree on three facts, so they live here once:
+ *
+ * - where the store is (`$CODEX_HOME/sessions`, else `~/.codex/sessions`);
+ * - which line shapes carry a user or assistant message;
+ * - which `response_item` user rows are injected context rather than input.
+ *
+ * ## The message shapes
+ *
+ * Codex has written conversation text under three shapes:
+ *
+ * - `event_msg` / `user_message` and `agent_message`, written by every version
+ * seen so far. `agent_message` carries a `phase` (`commentary` or
+ * `final_answer`); `user_message` carries none.
+ * - `event_msg` / `item_completed` with an `item.type` of `UserMessage` or
+ * `AgentMessage`, written by newer versions (0.152.1 and later).
+ * - `response_item` rows with `role: 'user'` or `'assistant'`. These are what
+ * the model saw, so the user rows mix real input with injected context.
+ *
+ * ## The injected-context rule
+ *
+ * Codex injects context as `response_item` user rows that start with a Markdown
+ * heading (`# AGENTS.md instructions for ...`) or an XML-style tag
+ * (``, ``, ``,
+ * ``, ``, ``, ``).
+ * Measured on a store of 2,916 rollouts (codex 0.120 to 0.163), every
+ * `response_item` user row with either prefix and no event twin was injected;
+ * the real prompts that start with `#` all have a `user_message` or
+ * `UserMessage` event twin, which this rule never filters. A fixed list of tag
+ * names would leak every wrapper Codex adds later (``
+ * alone was 1,355 rows on that store), so the rule is the prefix.
+ *
+ * @module codex-rollout-format
+ */
+
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+/** Codex's home directory: `$CODEX_HOME`, else `~/.codex`, exactly as codex resolves it. */
+export function codexHomeDir(): string {
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
+}
+
+/** The rollout store under {@link codexHomeDir}. */
+export function codexSessionsRoot(): string {
+ return join(codexHomeDir(), 'sessions');
+}
+
+/**
+ * Flatten Codex message content, which is a string or an array of blocks.
+ * Only blocks carrying a string `text` contribute (images and skills do not).
+ * Not trimmed: callers that want a preview trim.
+ */
+export function codexContentText(content: unknown): string {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .filter(
+ (b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
+ )
+ .map((b) => b.text)
+ .filter(Boolean)
+ .join('\n');
+}
+
+/** Injected context rather than typed input. Applies to `response_item` user rows only. */
+export function isCodexInjectedContext(text: string): boolean {
+ const start = text.trimStart();
+ return start.startsWith('#') || start.startsWith('<');
+}
+
+/** One rollout line's user or assistant message, whichever shape carried it. */
+export interface CodexLineMessage {
+ role: 'user' | 'assistant';
+ /** Untrimmed text, never empty. */
+ text: string;
+ /** `event` = user_message/agent_message, `item` = item_completed, `response` = response_item. */
+ shape: 'event' | 'item' | 'response';
+ /** Codex's own phase (`commentary` / `final_answer`) when the line carries one. */
+ phase?: string;
+ /** A `response_item` user row: may be injected context, see {@link isCodexInjectedContext}. */
+ injectionProne: boolean;
+}
+
+function record(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {};
+}
+
+function phaseOf(value: unknown): string | undefined {
+ return typeof value === 'string' && value ? value : undefined;
+}
+
+/** The message a parsed rollout line carries, or null for every other line. */
+export function codexMessageFromLine(entry: unknown): CodexLineMessage | null {
+ const e = record(entry);
+ const p = record(e.payload);
+ let role: CodexLineMessage['role'] | undefined;
+ let text = '';
+ let shape: CodexLineMessage['shape'] = 'event';
+ let phase: string | undefined;
+ if (e.type === 'event_msg') {
+ if (p.type === 'user_message' || p.type === 'agent_message') {
+ role = p.type === 'user_message' ? 'user' : 'assistant';
+ text = codexContentText(p.message);
+ phase = phaseOf(p.phase);
+ } else if (p.type === 'item_completed') {
+ const item = record(p.item);
+ if (item.type === 'UserMessage' || item.type === 'AgentMessage') {
+ role = item.type === 'UserMessage' ? 'user' : 'assistant';
+ shape = 'item';
+ text = codexContentText(item.content);
+ phase = phaseOf(item.phase);
+ }
+ }
+ } else if (e.type === 'response_item' && (p.role === 'user' || p.role === 'assistant')) {
+ role = p.role;
+ shape = 'response';
+ text = codexContentText(p.content);
+ phase = phaseOf(p.phase);
+ }
+ if (!role || !text) return null;
+ return { role, text, shape, phase, injectionProne: shape === 'response' && role === 'user' };
+}
diff --git a/src/codex-transcript.ts b/src/codex-transcript.ts
index 9f360ee3b..aabc0624c 100644
--- a/src/codex-transcript.ts
+++ b/src/codex-transcript.ts
@@ -37,13 +37,20 @@
* summaries), so they are read only when neither shape above appears, and
* the obvious injections are dropped.
*
+ * The shapes, the injected-context rule and the store location live in
+ * `codex-rollout-format.ts`, shared with the offline reader `local-history.ts`.
+ *
* @module codex-transcript
*/
import { open, readdir, stat } from 'node:fs/promises';
-import { homedir } from 'node:os';
import { join } from 'node:path';
+import {
+ codexMessageFromLine,
+ codexSessionsRoot,
+ isCodexInjectedContext as isInjectedContext,
+} from './codex-rollout-format.js';
import { LRUMap } from './utils/lru-map.js';
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
@@ -134,11 +141,6 @@ function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boole
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
}
-function codexSessionsRoot(): string {
- const home = process.env.CODEX_HOME || join(homedir(), '.codex');
- return join(home, 'sessions');
-}
-
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
async function readHead(path: string, bytes: number): Promise {
const fh = await open(path, 'r').catch(() => null);
@@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
- )
- .map((b) => b.text)
- .join('\n')
- .trim();
-}
-
-/** One line's user-prompt text, whichever of the three shapes it is. */
-function userPromptFromLine(entry: {
- type?: string;
- payload?: {
- type?: string;
- role?: string;
- content?: unknown;
- message?: unknown;
- item?: { type?: string; content?: unknown };
- };
-}): { text: string; injectionProne: boolean } | null {
- const p = entry.payload;
- if (!p) return null;
-
- if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
- const text = contentText(p.item.content);
- return text ? { text, injectionProne: false } : null;
- }
- if (entry.type === 'event_msg' && p.type === 'user_message') {
- const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
- return text ? { text, injectionProne: false } : null;
- }
- if (entry.type === 'response_item' && p.role === 'user') {
- const text = contentText(p.content);
- return text ? { text, injectionProne: true } : null;
- }
- return null;
-}
-
/**
- * Injected context rather than something the user typed. Codex prepends the
- * repository's AGENTS.md and wraps environment context in a tag, and both arrive
- * as `response_item` user rows.
+ * One line's user-prompt text, whichever of the three shapes it is. The shapes
+ * and the injected-context rule are shared with the offline history reader
+ * (`codex-rollout-format.ts`), so the two never disagree about what a prompt is.
*/
-function isInjectedContext(text: string): boolean {
- return text.startsWith('#') || text.startsWith('<');
+function userPromptFromLine(entry: unknown): { text: string; injectionProne: boolean } | null {
+ const message = codexMessageFromLine(entry);
+ if (!message || message.role !== 'user') return null;
+ const text = message.text.trim();
+ return text ? { text, injectionProne: message.injectionProne } : null;
}
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts
index de4a5542f..f5529ba3b 100644
--- a/src/config/cli-registry/schema.ts
+++ b/src/config/cli-registry/schema.ts
@@ -301,6 +301,9 @@ const capabilitiesSchema = z
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
+ // Keep the PTY's rows while a phone keyboard is up (inline renderers only).
+ // Optional: absent means the keyboard resizes, the safe default.
+ keyboardHold: z.literal('inline').optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
@@ -384,6 +387,35 @@ const capabilitiesSchema = z
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
+ // Same guard again: run over the rows under the composer each time guarded
+ // input reads the pane, and ~/.codeman/clis.json can set it.
+ composerFooterLine: z
+ .string()
+ .min(1)
+ .refine(
+ (src) => compileVersionRegex(src) !== null,
+ 'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
+ )
+ .optional(),
+ // Same guard again: run over the rows just above the composer by the same reads.
+ composerPopupLine: z
+ .string()
+ .min(1)
+ .refine(
+ (src) => compileVersionRegex(src) !== null,
+ 'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
+ )
+ .optional(),
+ // Single ASCII punctuation characters, never letters, digits or whitespace: each one
+ // refuses every message that starts (or whose last word starts) with it.
+ composerCommandPrefixes: z
+ .string()
+ .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters')
+ .optional(),
+ composerCompletionSigils: z
+ .string()
+ .regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters')
+ .optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts
index 1b0e00ea9..c3061e78c 100644
--- a/src/config/cli-registry/stock.ts
+++ b/src/config/cli-registry/stock.ts
@@ -13,6 +13,14 @@
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
+/**
+ * Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what
+ * codex prints when no effort is configured), as one regex alternation. Shared by the two
+ * patterns that read that footer (`modelDetect.screenLine` and
+ * `workDetect.composerFooterLine`), so a new level is a one-place edit.
+ */
+const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|');
+
const HOME_DIRS = {
local: '~/.local/bin',
usrLocal: '/usr/local/bin',
@@ -289,6 +297,10 @@ const CLAUDE: CliEntry = {
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
+ // Claude's default renderer is inline, and each keyboard-driven PTY height change made
+ // it reprint only the tail that fits, erasing transcript rows from tmux (measured on
+ // 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold.
+ keyboardHold: 'inline',
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -734,6 +746,34 @@ const CODEX: CliEntry = {
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
+ // The rows codex draws under an idle composer, tested one at a time by guarded
+ // stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default ·
+ // ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the
+ // `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older
+ // `NN% context left` footer. Indented, so the `›` composer row itself never matches.
+ composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`,
+ // Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/
+ // popup_state.rs, composer_layout.rs and the popups' render code and snapshots):
+ // every suggestion popup renders ABOVE the composer and keeps the footer below it,
+ // so the footer alone cannot tell `› /stat` with the command list open from an
+ // idle draft, and Enter then runs the highlighted `/status`. What sits in the
+ // block just above the composer (past its blank top padding row):
+ // command list (`/`) `› /status show current session ...`
+ // ` /statusline configure which items ...`
+ // skill and mention lists ` enter insert · esc close` (key-hint row)
+ // (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter`
+ // file search (`@`) ` loading...` / ` no matches`, else bare path
+ // rows, which no pattern can tell from prose
+ // The last case is why stage also refuses the triggers themselves (below).
+ composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`,
+ // Same source. A draft starting with `/` is a slash command (the popup opens while
+ // the caret is in its name, and Enter runs a command either way), and one starting
+ // with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups
+ // open for the word under the caret only: `@` (file search, or the unified mention
+ // list) and `$` (skills and mentions; whether a given `$word` opens it depends on
+ // codex's skill catalog, so every `$` word is refused).
+ composerCommandPrefixes: '/!',
+ composerCompletionSigils: '@$',
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
@@ -757,7 +797,7 @@ const CODEX: CliEntry = {
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
- screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
+ screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index 81fd48b44..6707d4aa1 100644
--- a/src/config/cli-registry/types.ts
+++ b/src/config/cli-registry/types.ts
@@ -432,6 +432,39 @@ export interface CliCapabilities {
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
+ /**
+ * Source of a regex every non-blank row BELOW an idle composer must match (at most
+ * three rows): the footer and hint rows this CLI draws there and nowhere else. It is
+ * what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove
+ * that the `promptGlyph` row they read is this CLI's own composer, at rest, and not
+ * a dialog option or a transcript line the agent printed. A CLI that omits it never
+ * takes guarded input: stage and submit refuse it. Tested one row at a time.
+ */
+ composerFooterLine?: string;
+ /**
+ * Source of a regex matching a row of a suggestion popup this CLI draws directly
+ * ABOVE its composer (a slash-command list, a file or mention search, its key-hint
+ * row). The footer below such a composer is unchanged, so without this guarded
+ * stage/submit would read the popup frame as an idle composer, and Enter would
+ * accept the highlighted entry instead of sending the draft. Tested one row at a
+ * time against the contiguous block just above the composer row, never further
+ * up the pane: a match there refuses the frame. See `readGuardedComposer()`.
+ */
+ composerPopupLine?: string;
+ /**
+ * Characters that, as the first character of a draft, make Enter run a command
+ * rather than send a prompt (codex: `/` slash commands, `!` shell commands).
+ * Guarded stage refuses such a message before typing; commands go through the
+ * ordinary input route.
+ */
+ composerCommandPrefixes?: string;
+ /**
+ * Characters that, starting the word under the caret, open a completion popup
+ * whose highlighted entry Enter accepts (codex: `@` file and mention search, `$`
+ * skills). Guarded stage refuses a message whose LAST word starts with one,
+ * before typing: the caret ends there, and the popup follows the caret.
+ */
+ composerCompletionSigils?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
@@ -563,6 +596,30 @@ export interface CliCapabilities {
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
+ /**
+ * `'inline'`: the CLI renders inline (its content stacks at the bottom of the
+ * main screen), so while a phone's on-screen keyboard is up the browser keeps
+ * the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`,
+ * terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through
+ * `window.__codemanKeyboardHoldModes`, which the server builds from this field.
+ *
+ * Why it exists: every PTY height change makes Claude Code's inline renderer
+ * clear the screen and reprint only the tail that fits (#672). Measured on
+ * Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's
+ * size printing 100 numbered lines: five keyboard open/close cycles (PTY
+ * 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in
+ * `tmux capture-pane` history, the other 25 gone for good; with the rows held
+ * the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained.
+ *
+ * ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height
+ * (codex: banner at the top, composer pinned to the bottom) cannot fit both
+ * ends above the keyboard and must be resized instead, so it stays absent.
+ * Absent means resize, the safe default for an unmeasured CLI. Even when set,
+ * the hold stands aside while the CLI reports mouse tracking (Claude's
+ * fullscreen mode, which lays out the full height and has not been measured
+ * losing lines, so it resizes as before), and for remote and docker sessions.
+ */
+ keyboardHold?: 'inline';
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts
index 7a9c18582..0db258b20 100644
--- a/src/config/server-timing.ts
+++ b/src/config/server-timing.ts
@@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000;
/** Pause between scheduled run iterations (ms) */
export const ITERATION_PAUSE_MS = 2000;
+// ============================================================================
+// Git Fetch Interval
+// ============================================================================
+
+/**
+ * How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A
+ * run lands within one tick of the interval the user set, because the interval is timed from the start of
+ * a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing.
+ */
+export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000;
+
// ============================================================================
// Mux Stats
// ============================================================================
diff --git a/src/daemon-control.ts b/src/daemon-control.ts
index cb09f3d7d..c52efd5ee 100644
--- a/src/daemon-control.ts
+++ b/src/daemon-control.ts
@@ -49,6 +49,7 @@ export interface WebLaunchOptions {
basePath?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
+ allowUnauthenticatedTunnel?: boolean;
multiuser?: boolean;
}
@@ -92,6 +93,7 @@ export function buildWebArgs(options: WebLaunchOptions): string[] {
if (options.basePath) args.push('--base-url', options.basePath);
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
+ if (options.allowUnauthenticatedTunnel) args.push('--allow-unauthenticated-tunnel');
if (options.multiuser) args.push('--multiuser');
return args;
}
diff --git a/src/deepseek-transcript.ts b/src/deepseek-transcript.ts
index dc374f101..f32139cca 100644
--- a/src/deepseek-transcript.ts
+++ b/src/deepseek-transcript.ts
@@ -26,13 +26,15 @@
* **1. One zstd FRAME per append, not one zstd stream.** The file is
* `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into
* its own frame and writing it at the end. `zstd -dc` handles that (frames
- * concatenate by definition), but Node's `zlib.zstdDecompress()` and
- * `createZstdDecompress()` both stop at the first frame end: measured on a real
- * 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned
- * 43,747 bytes / 56 lines. That is a silent truncation to the session header —
- * every call would have reported "no answer yet" forever. `decodeZstdFrames()`
- * below walks the frame headers itself and decompresses each frame, and
- * `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures.
+ * concatenate by definition), but before Node 24.22 and 26.10
+ * (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both
+ * stop at the first frame end, and the supported range still includes those
+ * versions: measured on a real 56-line transcript, Node returned 158 bytes /
+ * 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent
+ * truncation to the session header: every call would have reported "no answer
+ * yet" forever. `decodeZstdFrames()` below walks the frame headers itself and
+ * decompresses each frame, and `test/deepseek-transcript.test.ts` pins it
+ * against multi-frame fixtures.
*
* **2. The user's prompts are mixed with injected context.** Every turn also
* writes a `user/message` whose source is a plugin (the runtime-context
diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts
index fbafb4600..f2ce722da 100644
--- a/src/docker-hosts.ts
+++ b/src/docker-hosts.ts
@@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise
return readJsonArray(dockerCasesPath(configDir));
}
+/**
+ * The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases,
+ * but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases}
+ * reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an
+ * empty or half-written file, and to a caller deciding where git may run that must never look like "no
+ * Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault).
+ */
+export async function readDockerCasesOrThrow(configDir: string): Promise {
+ const path = dockerCasesPath(configDir);
+ let raw: string;
+ try {
+ raw = await fs.readFile(path, 'utf-8');
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
+ throw err;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = undefined;
+ }
+ if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`);
+ return parsed as DockerCase[];
+}
+
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise {
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
}
diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts
new file mode 100644
index 000000000..a563fd1b5
--- /dev/null
+++ b/src/git-fetch-scheduler.ts
@@ -0,0 +1,307 @@
+/**
+ * @fileoverview Git fetch on an interval: the sessions the user turned on, fetched by a tick the server owns
+ * (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`).
+ *
+ * The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer
+ * it is the only one that can run without being asked (cloning and worktree management write too, but those
+ * happen when the user asks for them). Which is why the whole feature is off until the user turns it on, in
+ * Settings → Bottom bar, per session in Session Options → Git.
+ *
+ * The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every
+ * tick reads the app setting FRESH, so a live toggle takes effect on the next tick instead of at boot, and
+ * turning the feature off actually stops the fetching. A session can override both the switch and the
+ * interval; a session with no override follows the app.
+ *
+ * Rules the code keeps and the tests pin:
+ * - Nothing runs when nothing is on: no repository set is read and nothing is spawned, so a quiet
+ * install costs one settings read per tick. A session that turned itself on still runs even while the
+ * app-wide switch is off: that is what a per-session switch has to mean.
+ * - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker
+ * workspace roots are checked again every tick, so a session whose case became a Docker case stops
+ * fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches
+ * nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a
+ * container can write to), the folders it picked stay due for the next tick, and it is logged once
+ * until a read succeeds again.
+ * - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one
+ * session: two sessions open on the same folder are the same clone, so they share one run and both get
+ * its result. State is keyed by `resolve(workingDir)`, deliberately not `realpath`, because `realpath`
+ * is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read.
+ * Every session open on a folder is remembered, so closing the one holding the override does not stop
+ * the folder's fetching while another session is still on it. A folder is due when ANY session open on it
+ * is due, and the tick decides that per folder in a second pass: the order the sessions were read cannot
+ * change which interval wins, so a 30 s session on a folder a 3600 s session last fetched ten minutes ago
+ * is fetched now. The folder is probed once, not once per session on it.
+ * - Before anything is fetched, the folder is probed BOUNDED with the default options (rule #516): a case
+ * on a mount that stopped answering is skipped, never touched, and a probe that answers `unknown` is not
+ * read as "not a folder".
+ * - A schedule is stamped at SELECTION, when a session goes into `due`, not when its run starts, so a
+ * tick that is still running cannot pick the same folder again; and a tick is skipped while the previous
+ * one is still going, so the bound below is the bound for the whole tick, not per tick.
+ * - At most `FETCH_CONCURRENCY` git processes at a time across the whole tick, because each is a git
+ * process: ONE bound is shared by every run of the tick, so `FETCH_CONCURRENCY` is the tick's ceiling
+ * rather than each folder's own. The folders get their OWN bound (`mapLimited`), because one semaphore
+ * cannot serve both levels: a folder that holds a slot and then waits for one for its own fetch is
+ * waiting on itself, and the folders that fill the bound are exactly the ones blocking it.
+ * - The interval is timed from the START of a run, so a run that takes three minutes does not push the
+ * next one out by three minutes; it lands on the tick after its own interval elapses.
+ * - The per-repository limit and the git timeout stay the module's defaults (`MAX_REPOS`,
+ * `DEFAULT_GIT_TIMEOUT_MS`): both settings are per device and never reach the server, so the server
+ * cannot know what a device chose.
+ * - A failure is reported, not retried harder: the folder keeps its schedule and gets its next run at its
+ * own interval. A settings read that fails skips this tick only, rather than deciding from defaults.
+ * settings.json is replaced atomically, so such a failure is a file that does not parse (a hand edit) or
+ * a real read error, which lasts until someone fixes it: it is logged once, and again only after a read
+ * has succeeded in between, not every 15 s tick.
+ *
+ * @module git-fetch-scheduler
+ */
+
+import { resolve } from 'node:path';
+import { getErrorMessage } from './types.js';
+import {
+ FETCH_CONCURRENCY,
+ MAX_GIT_FETCH_INTERVAL_SECONDS,
+ MIN_GIT_FETCH_INTERVAL_SECONDS,
+ gitFetchStatusFrom,
+ type GitFetchOptions,
+ type GitFetchRun,
+ type GitFetchSettings,
+ type GitFetchStatus,
+ type GitFetchTrigger,
+} from './git-fetch.js';
+import { clampInt, DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS, createLimiter, mapLimited } from './git-workspace-status.js';
+import type { PathProbeKind } from './utils/bounded-path-probe.js';
+
+/** What the scheduler needs from a session: the facts a decision needs, and nothing else. */
+export interface GitFetchSessionView {
+ id: string;
+ workingDir: string;
+ /** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
+ remote?: unknown;
+ docker?: unknown;
+ gitFetchEnabled?: boolean;
+ gitFetchIntervalSeconds?: number;
+}
+
+/** A session open on a folder, with the interval that makes IT due. One folder's sessions are a group. */
+interface GroupMember {
+ view: GitFetchSessionView;
+ intervalSeconds: number;
+}
+
+export interface GitFetchPorts {
+ sessionIds(): Iterable;
+ getSession(id: string): GitFetchSessionView | undefined;
+ /** Read FRESH every tick; that is the whole point of the tick-driven shape. */
+ readSettings(): Promise;
+ /**
+ * Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the
+ * tick then fetches nothing and the folders it picked are due again on the next tick.
+ */
+ dockerWorkspaces(): Promise;
+ /**
+ * Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called
+ * with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is
+ * skipped, not fetched.
+ */
+ probePath(path: string): Promise;
+ /** Called through `fetchWorkspaceOnce`, so a run already going for this folder is joined, not stacked. */
+ fetch(cwd: string, opts: GitFetchOptions, trigger: GitFetchTrigger): Promise;
+ publish(id: string, status: GitFetchStatus): void;
+ log?(message: string): void;
+}
+
+/**
+ * What one session will actually do: its own override wins, otherwise the app-wide setting. The interval is
+ * clamped again here because a persisted value can predate the schema's bounds.
+ */
+export function effectiveGitFetch(
+ view: GitFetchSessionView,
+ settings: GitFetchSettings
+): { enabled: boolean; intervalSeconds: number } {
+ return {
+ enabled: view.gitFetchEnabled ?? settings.enabled,
+ intervalSeconds: clampInt(
+ view.gitFetchIntervalSeconds ?? settings.intervalSeconds,
+ MIN_GIT_FETCH_INTERVAL_SECONDS,
+ MAX_GIT_FETCH_INTERVAL_SECONDS,
+ MIN_GIT_FETCH_INTERVAL_SECONDS
+ ),
+ };
+}
+
+export class GitFetchScheduler {
+ private readonly ports: GitFetchPorts;
+ /** When each FOLDER last started a run; timed from the start, not the end. */
+ private readonly lastRunAt = new Map();
+ private readonly inFlight = new Set();
+ /** Session → folder, so `remove()` can keep the folder's schedule while other sessions are still on it. */
+ private readonly folderOf = new Map();
+ private running = false;
+ private stopped = false;
+ /** The last settings read failed and was logged; cleared by the next read that succeeds. */
+ private settingsUnreadable = false;
+ /** The same for the Docker case list. */
+ private dockerCasesUnreadable = false;
+
+ constructor(ports: GitFetchPorts) {
+ this.ports = ports;
+ }
+
+ /** One tick: fetch every folder whose sessions are on and due. Returns how many folders ran. */
+ async tick(now = Date.now()): Promise {
+ if (this.stopped || this.running) return 0;
+ this.running = true;
+ try {
+ let settings: GitFetchSettings;
+ try {
+ settings = await this.ports.readSettings();
+ } catch (err) {
+ // Writes are atomic, so this is a real read error or a file that does not parse (a hand edit), and it
+ // stays that way until it is fixed: logged once, not on every tick. The reader never puts the file's
+ // text in its error (readGitFetchSettings), since settings.json can hold an API key.
+ if (!this.settingsUnreadable) {
+ this.settingsUnreadable = true;
+ this.ports.log?.(
+ `git fetch: settings unreadable, ticks skipped until it reads again: ${getErrorMessage(err)}`
+ );
+ }
+ return 0;
+ }
+ this.settingsUnreadable = false;
+
+ // Pass 1: who is on what, before anything is decided. Every session open on a folder is recorded here,
+ // not only the one that would get picked, so `remove()` can still see who is open on it when the session
+ // holding the override is the one that closes.
+ const groups = new Map();
+ for (const id of this.ports.sessionIds()) {
+ const view = this.ports.getSession(id);
+ if (!view || view.remote || view.docker) continue;
+ const effective = effectiveGitFetch(view, settings);
+ if (!effective.enabled) continue;
+ const folder = resolve(view.workingDir);
+ this.folderOf.set(id, folder);
+ const group = groups.get(folder);
+ const member = { view, intervalSeconds: effective.intervalSeconds };
+ if (group) group.push(member);
+ else groups.set(folder, [member]);
+ }
+
+ // Pass 2: the folder's turn, decided from the whole group rather than from whichever session happened to
+ // be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a
+ // folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run
+ // satisfies the long interval too. Deciding in one pass means the session read first owns the clock: a
+ // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the
+ // run belongs to the folder, so no session in the group should.
+ const due = new Map();
+ // What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching
+ // nothing (an unreadable Docker case list, below) can leave the folders due again.
+ const stampedOver = new Map();
+ for (const [folder, group] of groups) {
+ if (this.inFlight.has(folder)) continue;
+ const started = this.lastRunAt.get(folder);
+ const isDue = started === undefined || group.some((m) => now - started >= m.intervalSeconds * 1000);
+ if (!isDue) continue;
+ // Bounded probe (rule #516), once per folder rather than once per session on it.
+ const kind = await this.ports.probePath(folder);
+ if (kind !== 'directory') {
+ this.ports.log?.(`git fetch: ${folder} skipped, answered ${kind}`);
+ continue;
+ }
+ this.inFlight.add(folder);
+ // The clock starts at the selection, so a slow folder does not shift its own schedule.
+ stampedOver.set(folder, started);
+ this.lastRunAt.set(folder, now);
+ due.set(folder, group);
+ }
+ if (!due.size) return 0;
+
+ // Only now, because nothing above needs it: a quiet tick does not read the Docker case list.
+ let dockerWorkspaces: string[];
+ try {
+ dockerWorkspaces = await this.ports.dockerWorkspaces();
+ } catch (err) {
+ // Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a
+ // container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared
+ // stays cleared) and the folders are due again on the next tick instead of after their interval.
+ for (const [folder, started] of stampedOver) {
+ this.inFlight.delete(folder);
+ if (this.lastRunAt.get(folder) !== now) continue;
+ if (started === undefined) this.lastRunAt.delete(folder);
+ else this.lastRunAt.set(folder, started);
+ }
+ if (!this.dockerCasesUnreadable) {
+ this.dockerCasesUnreadable = true;
+ this.ports.log?.(
+ `git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}`
+ );
+ }
+ return 0;
+ }
+ this.dockerCasesUnreadable = false;
+ // ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own:
+ // each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per
+ // run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once.
+ const fetches = createLimiter(FETCH_CONCURRENCY);
+ // The folders get their OWN bound, because the same semaphore cannot serve both levels: a folder that
+ // holds a slot and then waits for a slot for its own fetch is waiting on itself: once
+ // `FETCH_CONCURRENCY` folders fill the bound, the ones holding slots are exactly the ones that can
+ // never get one back. Both bounds are `FETCH_CONCURRENCY`, so the tick still never runs more git
+ // processes than the module's ceiling.
+ await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => {
+ try {
+ const run = await this.ports.fetch(
+ folder,
+ {
+ dockerWorkspaces,
+ maxRepos: MAX_REPOS,
+ timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
+ limiter: fetches,
+ },
+ 'interval'
+ );
+ // Every session on the folder gets the SAME run, with the trigger of the run that actually happened:
+ // if this tick joined a run a click had already started, the row has to say it was a click, because
+ // that is what fetched it.
+ for (const member of group) this.ports.publish(member.view.id, gitFetchStatusFrom(run.report, run.trigger));
+ } catch (err) {
+ // The run that failed keeps its reason in the panel's own row; here only the folder-level failure is
+ // worth a line, because nothing else would say a scheduled fetch could not run at all.
+ this.ports.log?.(`git fetch: ${folder} could not run: ${getErrorMessage(err)}`);
+ } finally {
+ this.inFlight.delete(folder);
+ }
+ });
+ return due.size;
+ } finally {
+ this.running = false;
+ }
+ }
+
+ /**
+ * A session that closed or was detached: its schedule goes with it, but the FOLDER's stays while another
+ * session is still open on it: the run belongs to the clone, not to the session that asked for it.
+ */
+ remove(id: string): void {
+ const folder = this.folderOf.get(id);
+ this.folderOf.delete(id);
+ if (!folder) return;
+ const stillOpen = [...this.folderOf.values()].includes(folder);
+ if (!stillOpen) {
+ this.lastRunAt.delete(folder);
+ this.inFlight.delete(folder);
+ }
+ }
+
+ stop(): void {
+ this.stopped = true;
+ this.lastRunAt.clear();
+ this.inFlight.clear();
+ this.folderOf.clear();
+ }
+
+ /** Folders the scheduler still remembers a run for, so a test can see a `remove()` actually took. */
+ tracked(): number {
+ return new Set([...this.lastRunAt.keys(), ...this.inFlight]).size;
+ }
+}
diff --git a/src/git-fetch.ts b/src/git-fetch.ts
new file mode 100644
index 000000000..a2f4b2ba4
--- /dev/null
+++ b/src/git-fetch.ts
@@ -0,0 +1,506 @@
+/**
+ * @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click
+ * (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on
+ * (`src/git-fetch-scheduler.ts`).
+ *
+ * The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last
+ * fetch, so a number that is stale can only be made current by fetching. This is the only git command the
+ * Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and
+ * managing worktrees write too, but those are things the user asked for at the moment they asked; this is
+ * the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working
+ * tree: `git fetch` updates remote-tracking
+ * refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is
+ * already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own
+ * `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched
+ * by at most one run at a time (`fetchWorkspaceOnce`).
+ *
+ * Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`,
+ * `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO
+ * half runs one bounded `git fetch --quiet` per repository.
+ *
+ * Rules the code keeps and the tests pin:
+ * - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route
+ * already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is
+ * checked before any git runs: a repository a container can write to is refused outright, because a
+ * planted config would run on the host, and `git fetch` is exactly the command that can run a
+ * repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance
+ * that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only
+ * runs it after the user opts in.
+ * - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder
+ * instead of starting a second one, because two fetches in one clone collide on the ref lock.
+ * - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at
+ * most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the
+ * whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder.
+ * - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a
+ * login fails fast instead of hanging the request on an invisible username prompt. Codeman collects
+ * no credentials; a repository the user's own git is already signed in to works because that is the
+ * user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is
+ * a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the
+ * user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups
+ * that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new`
+ * writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a
+ * write.
+ * - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns
+ * `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go
+ * out while a helper is still running, and on a timer that repeats every tick with nobody watching.
+ * `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL
+ * `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used
+ * rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue:
+ * fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue.
+ * - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`),
+ * and the browser writes it with `textContent` only.
+ *
+ * @module git-fetch
+ */
+
+import { execFile, spawn } from 'node:child_process';
+import { basename, relative, resolve } from 'node:path';
+import { promisify } from 'node:util';
+import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js';
+import {
+ createLimiter,
+ DEFAULT_GIT_TIMEOUT_MS,
+ type Limiter,
+ resolveOverviewLimits,
+ resolveWorkspaceRepos,
+ type GitRunner,
+} from './git-workspace-status.js';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE
+ * bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to
+ * `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both
+ * levels: a folder holding a slot would then wait for a slot its own fetch needs.
+ */
+export const FETCH_CONCURRENCY = 4;
+/** A fetch on a busy remote can print a lot; a bound on what we will hold. */
+const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024;
+/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */
+const MAX_FETCH_STDERR_BYTES = 8_192;
+/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */
+const MAX_SSH_COMMAND_BYTES = 4_096;
+/**
+ * How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a
+ * const so a test can say how long it is willing to wait for the group to actually die.
+ */
+export const FETCH_KILL_ESCALATION_MS = 3_000;
+
+/**
+ * Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no
+ * fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a
+ * repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a
+ * fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra
+ * write this module must not do. Kept as a const so a test can pin it without spawning git.
+ */
+export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
+
+/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */
+export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300;
+export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30;
+export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600;
+
+export interface GitFetchSettings {
+ enabled: boolean;
+ intervalSeconds: number;
+}
+
+/**
+ * A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer
+ * until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted.
+ * Pure, so the reading itself is testable without a settings file.
+ */
+export function gitFetchSettingsFrom(settings: Record | undefined): GitFetchSettings {
+ const raw = settings?.gitFetchIntervalSeconds;
+ const seconds =
+ typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS;
+ return {
+ enabled: settings?.gitFetchEnabled === true,
+ intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)),
+ };
+}
+
+/**
+ * The only git command this module runs. Kept as a function so a test can pin the verb: nothing here
+ * commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process
+ * `cwd` and the argv is fixed.
+ */
+export function buildFetchArgs(): string[] {
+ return ['fetch', '--quiet'];
+}
+
+/**
+ * The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate
+ * so pinning the verb here does not have to say anything about maintenance, and so both can be tested
+ * without spawning.
+ *
+ * A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47
+ * exits quietly and this code is never reached, older git says "no default remote repository"), so the
+ * branch is pinned on literal fixtures and the code the panel shows is the code this writes.
+ */
+export type FetchFailureCode = GitFailureCode | 'NO_REMOTE';
+
+export interface FetchFailure {
+ code: FetchFailureCode;
+ /** User-facing summary. */
+ message: string;
+ /** Tail of git's own stderr, control-stripped and credential-redacted. */
+ stderr: string;
+}
+
+/**
+ * A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the
+ * network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout
+ * wording is written for clones, so the fetch path replaces it with what the user can actually do.
+ */
+export function describeFetchFailure(err: unknown): FetchFailure {
+ const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean };
+ const stderr = typeof e.stderr === 'string' ? e.stderr : '';
+ const spawnError = typeof e.message === 'string' ? e.message : undefined;
+ const clean = sanitizeGitOutput(stderr);
+ const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
+ if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) {
+ return {
+ code: 'NO_REMOTE',
+ message: 'This repository has no remote, so there is nothing to fetch.',
+ stderr: clean,
+ };
+ }
+ const timedOut = e.code === 'ETIMEDOUT' || e.killed === true;
+ const failure = classifyGitFailure(stderr, timedOut, spawnError);
+ if (failure.code === 'TIMEOUT')
+ failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.';
+ return failure;
+}
+
+/**
+ * The environment a fetch gets, as a pure decision so it can be pinned without spawning git.
+ *
+ * `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads
+ * `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would
+ * override a repository that already chose its own ssh (an alternative host, an identity file, a jump box)
+ * and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it
+ * after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would
+ * hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the
+ * whole decision here: the caller supplies the value it read, and this decides between the user's
+ * environment and the repository's own answer.
+ */
+export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv {
+ const env = gitNonInteractiveEnv(base);
+ // `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the
+ // case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in
+ // the environment, wins over it.
+ if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND;
+ return env;
+}
+
+/**
+ * The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch:
+ * git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means
+ * not writing one. `--get` reads local and global config, which is the value git would have used, and no
+ * hook or filter runs on the way to it.
+ *
+ * The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot
+ * add a fifth git process or push a fetch past the user's timeout: it spends part of it.
+ */
+export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise {
+ try {
+ const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
+ cwd,
+ timeout: timeoutMs,
+ maxBuffer: MAX_SSH_COMMAND_BYTES,
+ env: gitNonInteractiveEnv(),
+ });
+ return stdout.trim() || null;
+ } catch {
+ // A repository with no such setting, or a read that could not finish: the default is what git would have
+ // used anyway, and a fetch must not fail because the check for a config value failed.
+ return null;
+ }
+}
+
+/**
+ * Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a
+ * required lock anyway.
+ *
+ * `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the
+ * caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled
+ * when the answer goes out, because the promise is a promise about the answer while the group still has to
+ * die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so
+ * can leave a `git-remote-http` child running after a timed-out call.
+ *
+ * The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a
+ * spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit
+ * rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real
+ * failure as a clean run with an empty reason.
+ *
+ * `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read
+ * left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers
+ * as a timeout without starting the fetch.
+ */
+export const runFetch: GitRunner = async (cwd, args, opts) => {
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
+ const startedAt = Date.now();
+ const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
+ if (remainingMs <= 0) {
+ throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
+ stderr: '',
+ killed: true,
+ code: 'ETIMEDOUT',
+ });
+ }
+
+ return new Promise((resolve, reject) => {
+ let child: ReturnType;
+ try {
+ child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], {
+ cwd,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: true,
+ });
+ } catch (err) {
+ reject(err);
+ return;
+ }
+
+ let stdout = '';
+ let stderr = '';
+ let stdoutBytes = 0;
+ let timedOut = false;
+ let settled = false;
+ let killTimer: NodeJS.Timeout | undefined;
+
+ const killTree = (signal: NodeJS.Signals) => {
+ try {
+ if (child.pid) process.kill(-child.pid, signal);
+ } catch {
+ try {
+ child.kill(signal);
+ } catch {
+ /* already gone */
+ }
+ }
+ };
+
+ const fail = (spawnError?: string, exitCode?: number | null) => {
+ if (settled) return;
+ settled = true;
+ // Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting
+ // for, and cancelling it is the bug this runner exists to fix.
+ clearTimeout(timeoutTimer);
+ reject(
+ Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), {
+ stderr,
+ killed: timedOut,
+ code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined),
+ })
+ );
+ };
+
+ const timeoutTimer = setTimeout(() => {
+ timedOut = true;
+ killTree('SIGTERM');
+ killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS);
+ fail();
+ }, remainingMs);
+
+ child.stdout?.on('data', (chunk: Buffer) => {
+ stdoutBytes += chunk.length;
+ if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8');
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ stderr += chunk.toString('utf-8');
+ if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES);
+ });
+
+ const finish = (code: number | null) => {
+ if (settled) return;
+ // execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real
+ // git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null`
+ // means a signal, reachable only when something outside this runner killed the group: our own timeout
+ // has already answered by then, and `settled` above is what keeps that answer the answer.
+ if (code !== 0) return fail(undefined, code);
+ settled = true;
+ clearTimeout(timeoutTimer);
+ if (killTimer) clearTimeout(killTimer);
+ resolve(stdout);
+ };
+
+ child.on('error', (err) => fail(String(err)));
+ child.on('close', (code) => finish(code));
+ });
+};
+
+export interface GitFetchEntry {
+ /** Folder name of the repository (its root's basename), as the panel names it. */
+ name: string;
+ /** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
+ path: string;
+ ok: boolean;
+ failure?: FetchFailure;
+}
+
+export interface GitFetchReport {
+ /** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
+ state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
+ reason?: 'remote' | 'docker';
+ error?: string;
+ /** One entry per repository fetched, in the same order the panel lists them. */
+ repos: GitFetchEntry[];
+ /** More than `repoLimit` repositories were found; only the first were fetched. */
+ reposTruncated: boolean;
+ /** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */
+ repoLimit?: number;
+ /** When the last repository finished, so the panel can say what the "behind" number is now based on. */
+ fetchedAt: number;
+}
+
+/**
+ * What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says
+ * whether the user clicked or the interval fired, because a number the user did not ask for should read
+ * differently from one they did.
+ */
+/** What a run was, and why it happened. */
+export type GitFetchTrigger = 'click' | 'interval';
+
+export interface GitFetchStatus {
+ ranAt: number;
+ trigger: GitFetchTrigger;
+ /** As in the report: `ok`, or the state that stopped the run. */
+ state: GitFetchReport['state'];
+ /** Repositories this run covered. */
+ repos: number;
+ /** How many of them could not be fetched. */
+ failed: number;
+ /** The first failure, named: enough for one row without reprinting the whole list. */
+ firstFailure?: { name: string; code: FetchFailureCode };
+}
+
+/**
+ * One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that
+ * run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch
+ * anything itself, and the row the panel shows has to describe the fetch that actually happened.
+ */
+export interface GitFetchRun {
+ report: GitFetchReport;
+ trigger: GitFetchTrigger;
+}
+
+export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus {
+ const failed = report.repos.filter((r) => !r.ok);
+ const status: GitFetchStatus = {
+ ranAt: report.fetchedAt,
+ trigger,
+ state: report.state,
+ repos: report.repos.length,
+ failed: failed.length,
+ };
+ const first = failed[0];
+ if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code };
+ return status;
+}
+
+/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */
+export interface GitFetchOptions {
+ git?: GitRunner;
+ home?: string;
+ dockerWorkspaces?: string[];
+ maxRepos?: number;
+ timeoutMs?: number;
+ /**
+ * The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick
+ * rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could
+ * have that many git processes going at once inside each one.
+ */
+ limiter?: Limiter;
+}
+
+export const emptyReport = (state: GitFetchReport['state'], extra: Partial = {}): GitFetchReport => ({
+ state,
+ repos: [],
+ reposTruncated: false,
+ fetchedAt: Date.now(),
+ ...extra,
+});
+
+async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise {
+ try {
+ await git(dir, buildFetchArgs(), { timeoutMs });
+ return null;
+ } catch (err) {
+ return describeFetchFailure(err);
+ }
+}
+
+/**
+ * Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot
+ * disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository
+ * stays in the status list: the panel must show that something was not fetched rather than quietly skip it.
+ */
+export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise {
+ const where = await resolveWorkspaceRepos(cwd, opts);
+ if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' });
+ if (where.kind === 'error') return emptyReport('error', { error: where.error });
+ const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs;
+ if (!dirs.length) return emptyReport('not-a-repo');
+ const { timeoutMs } = resolveOverviewLimits(opts);
+ const runner = opts.git ?? runFetch;
+ const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY);
+ const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs));
+ const repos: GitFetchEntry[] = dirs.map((dir, i) => {
+ const base = { name: basename(dir), path: relative(cwd, dir) || '.' };
+ const failure = failures[i];
+ return failure ? { ...base, ok: false, failure } : { ...base, ok: true };
+ });
+ return {
+ state: 'ok',
+ repos,
+ reposTruncated: where.kind === 'children' ? where.truncated : false,
+ repoLimit: resolveOverviewLimits(opts).maxRepos,
+ fetchedAt: Date.now(),
+ };
+}
+
+/**
+ * Runs already going, keyed by the resolved folder. A request for a folder another run is already working on
+ * JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on
+ * git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would
+ * otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point
+ * is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot
+ * report itself as the fetch.
+ *
+ * Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516
+ * bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that
+ * resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so
+ * a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded.
+ */
+const lane = new Map>();
+
+export async function fetchWorkspaceOnce(
+ folder: string,
+ opts: GitFetchOptions = {},
+ trigger: GitFetchTrigger
+): Promise {
+ const key = resolve(folder);
+ const going = lane.get(key);
+ // The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it
+ // must not report itself as having.
+ if (going) return going;
+ const run: Promise = fetchGitWorkspaceOverview(key, opts)
+ .then((report) => ({ report, trigger }))
+ .finally(() => {
+ // Only the run that is still the lane's own: a later run for the same folder must not be dropped by an
+ // earlier one finishing late.
+ if (lane.get(key) === run) lane.delete(key);
+ });
+ lane.set(key, run);
+ return run;
+}
+
+/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */
+export function clearGitFetchLane(): void {
+ lane.clear();
+}
diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts
index 8850542a3..2a5a8230d 100644
--- a/src/git-workspace-status.ts
+++ b/src/git-workspace-status.ts
@@ -22,7 +22,8 @@
*
* Rules the code keeps and the tests pin:
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
- * the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
+ * the last fetch (the UI says so, and the Fetch button is a separate user-triggered path in
+ * `src/git-fetch.ts`, never a poll); "ahead" and the unpushed list are exact against the
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
* refreshing the index, so polling cannot contend with the agent's own git commands.
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
@@ -596,8 +597,55 @@ export async function discoverChildRepos(
const discoveryCache = new Map();
-/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
-async function mapLimited(items: T[], limit: number, fn: (item: T) => Promise): Promise {
+/**
+ * A slot count fixed ONCE, so callers share it instead of each getting its own bound (see the fetch path):
+ * the count lives in the closure, so `limit` is the ceiling for everything going through THIS limiter at
+ * once, not for each call to it, which is what makes a scheduler tick's bound honest.
+ * ⚠️ One limiter cannot bound two levels of the same work: a caller that holds a slot and then waits for
+ * another slot for the work IT starts cannot get one, because the holders are exactly the ones waiting. The
+ * outer level needs its own bound (`mapLimited`), which is why the tick shares one for fetches and keeps a
+ * separate one for folders.
+ */
+export type Limiter = (items: T[], fn: (item: T) => Promise) => Promise;
+
+export function createLimiter(limit: number): Limiter {
+ let inUse = 0;
+ const waiting: Array<() => void> = [];
+ const acquire = (): Promise => {
+ if (inUse < limit) {
+ inUse++;
+ return Promise.resolve();
+ }
+ return new Promise((done) => {
+ waiting.push(() => {
+ inUse++;
+ done();
+ });
+ });
+ };
+ const release = () => {
+ inUse--;
+ const next = waiting.shift();
+ if (next) next();
+ };
+ return async (items: T[], fn: (item: T) => Promise): Promise => {
+ const out: R[] = new Array(items.length);
+ await Promise.all(
+ items.map(async (item, i) => {
+ await acquire();
+ try {
+ out[i] = await fn(item);
+ } finally {
+ release();
+ }
+ })
+ );
+ return out;
+ };
+}
+
+/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. Per call; `createLimiter` shares one bound across calls. */
+export async function mapLimited(items: T[], limit: number, fn: (item: T) => Promise): Promise {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
@@ -634,7 +682,7 @@ export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: un
};
}
-type WorkspaceRepos =
+export type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
@@ -643,9 +691,10 @@ type WorkspaceRepos =
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
- * below the folder. The overview and the diff route both go through here, so they cannot disagree.
+ * below the folder. The overview, the fetch route and the diff route all go through here, so they cannot
+ * disagree about which repositories belong to a workspace.
*/
-async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise {
+export async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise {
const now = opts.now ?? Date.now;
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
diff --git a/src/hooks-config.ts b/src/hooks-config.ts
index f1bd7ebc9..3c995e133 100644
--- a/src/hooks-config.ts
+++ b/src/hooks-config.ts
@@ -42,6 +42,7 @@ import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
+import { gitFetchSettingsFrom, type GitFetchSettings } from './git-fetch.js';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
@@ -1158,8 +1159,9 @@ export async function readKeepWarmSettings(): Promise {
/**
* The same two keys for the keep-warm service, which keeps what it reads until the next
* settings change. A missing file is the defaults, but any other read or parse failure rejects
- * rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
- * and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
+ * rather than falling back: settings.json is replaced atomically, so such a failure is a real
+ * read error or a file that does not parse (a hand edit), never a write in progress, and a
+ * fallback kept from it would hold keep-warm off until the next keep-warm settings change.
*/
export async function readKeepWarmSettingsOrThrow(): Promise {
let text: string;
@@ -1169,7 +1171,40 @@ export async function readKeepWarmSettingsOrThrow(): Promise {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
throw err;
}
- return keepWarmSettingsFrom(JSON.parse(text) as Record);
+ return keepWarmSettingsFrom(parseSettingsText(text));
+}
+
+/**
+ * The Git fetch interval's app-wide default (`gitFetchEnabled`, absent = OFF) and its cap
+ * (`gitFetchIntervalSeconds`, seconds between runs). Read FRESH on every tick by the scheduler, so a live
+ * toggle takes effect on the next tick and turning it off actually stops the fetching. A missing file is
+ * the defaults (OFF), but any other read or parse failure rejects and the scheduler skips that tick rather
+ * than deciding from defaults. settings.json is replaced atomically (a temp file renamed into place, see
+ * settings-writer.ts), so such a failure is a real read error or a file that does not parse (a hand edit),
+ * never a write in progress, and the scheduler logs it once rather than every tick.
+ */
+export async function readGitFetchSettings(): Promise {
+ let text: string;
+ try {
+ text = await readFile(SETTINGS_PATH, 'utf-8');
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({});
+ throw err;
+ }
+ return gitFetchSettingsFrom(parseSettingsText(text));
+}
+
+/**
+ * settings.json's text for the two readers above, which reject a file that does not parse. The parser's
+ * own message is never repeated, as in settings-writer.ts: V8 quotes the text around the fault, settings.json
+ * can hold an API key (`voiceSettings.apiKey`), and both callers log the error they get.
+ */
+function parseSettingsText(text: string): Record {
+ try {
+ return JSON.parse(text) as Record;
+ } catch {
+ throw new Error(`${SETTINGS_PATH} is not valid JSON`);
+ }
}
function keepWarmSettingsFrom(settings: Record): KeepWarmSettings {
diff --git a/src/local-history.ts b/src/local-history.ts
new file mode 100644
index 000000000..4ff244bf8
--- /dev/null
+++ b/src/local-history.ts
@@ -0,0 +1,842 @@
+/**
+ * @fileoverview Offline reader for saved Codex conversations, behind
+ * `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from
+ * disk directly: no HTTP route, no running server and no managed session.
+ *
+ * ## Selecting conversations
+ *
+ * Exactly one selector: `file` (one rollout, no store search), `sessionId` (a
+ * native thread id or unique prefix of at least 8 characters) or
+ * `rootSessionId` (a full id plus every transitive descendant). Identity always
+ * comes from each file's `session_meta`, never from its filename.
+ *
+ * ## Budgets
+ *
+ * Three separate bounds, each raisable from the CLI up to a fixed maximum, and
+ * each failing closed with an error rather than returning a partial answer:
+ *
+ * - `metadataBytes`: the head reads that attribute every rollout in the store.
+ * A `session_meta` line embeds Codex's base instructions (about 20 KiB on
+ * every rollout measured), so this scales with the store, not the query,
+ * and is kept apart from the transcript budget: a lookup on a store of
+ * thousands of rollouts must not spend the budget meant for the transcripts
+ * it returns. Each file's head read is also capped at 128 KiB.
+ * - `scanBytes`: the selected transcripts, read whole.
+ * - `maxEntries`: directory entries visited.
+ *
+ * ## Forks and sub-agents
+ *
+ * A thread Codex forks or spawns as a sub-agent can carry a copy of its
+ * parent's history in its own file (codex-rs `ForkPersistence::Copied`, written
+ * by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the
+ * parent's `session_meta`, see `keep_forked_rollout_item` in
+ * `core/src/agent/control/spawn.rs`). Such a file starts with its own
+ * `session_meta`, then the parent's, then the copied records, then one
+ * `thread_settings_applied` event, then the child's own records. The copy
+ * already lives in the parent's own file, so it is dropped here:
+ *
+ * - Paginated history (`history_mode: "paginated"`) numbers every record with
+ * an `ordinal` and declares `subagent_history_start_ordinal`; records below
+ * it are inherited, exactly as Codex's own projection treats them
+ * (`thread-store/src/local/thread_history_materialization.rs`). A paginated
+ * thread with a `history_base` references its prefix in another file and has
+ * nothing inherited to drop.
+ * - Legacy history has no ordinals. The prefix runs from the inherited
+ * `session_meta` to the `thread_settings_applied` event Codex appends after
+ * the copy. A legacy copy keeps the parent's own `thread_settings_applied`
+ * events too, so when more than one candidate follows, the boundary is the
+ * last one whose preceding messages are still a prefix of the parent's own
+ * messages, read from the parent's file (once: a family read that selects
+ * the parent reuses that same read, so no file is charged to `scanBytes`
+ * twice). Without a readable parent file that cannot be proven: the cut then
+ * falls at the LAST candidate, so parent text is never attributed to the
+ * child, and the result says so (`unverifiedPrefixes`, `complete: false`).
+ * Any failure of the parent's read other than a budget leads here, rather
+ * than failing the child whose own file is intact.
+ *
+ * A later `session_meta` must be the file's own id or a declared ancestor
+ * (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and
+ * transitively the ancestors those declare); any other id still refuses the
+ * whole file rather than attribute one conversation's answer to another.
+ *
+ * ## Compressed rollouts
+ *
+ * Codex's `local_thread_store_compression` feature rewrites cold rollouts to
+ * `*.jsonl.zst` in place. This reader does not decompress them: a store scan
+ * counts each one in `compressedFiles` (so `complete` is false and a not-found
+ * error names them), skipping one whose plain `.jsonl` sibling exists, as
+ * Codex's own discovery does. A Zstandard file given to `file` is refused by
+ * its magic number, whatever its name.
+ *
+ * ## Live rollouts
+ *
+ * A running Codex appends to its rollout. Reads take a snapshot: the file's
+ * identity (device and inode) must match the attributed one and its size may
+ * only have grown; everything up to the last complete line is read, and an
+ * unterminated tail is reported as a partial record.
+ *
+ * ## Format
+ *
+ * The line shapes and the injected-context rule are shared with the Past
+ * Sessions scanner through `codex-rollout-format.ts`.
+ *
+ * @module local-history
+ */
+import { constants } from 'node:fs';
+import { isUtf8 } from 'node:buffer';
+import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises';
+import { basename, join, resolve } from 'node:path';
+
+import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js';
+
+const KIB = 1024;
+const MIB = 1024 * KIB;
+const GIB = 1024 * MIB;
+
+export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB;
+export const HISTORY_MAX_SCAN_BYTES = 256 * MIB;
+export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB;
+export const HISTORY_MAX_METADATA_BYTES = 16 * GIB;
+export const HISTORY_DEFAULT_MAX_ENTRIES = 65536;
+export const HISTORY_MAX_ENTRIES = 1048576;
+export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192;
+export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB;
+/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */
+const METADATA_HEAD_BYTES = 128 * KIB;
+/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */
+const METADATA_CHUNK_BYTES = 32 * KIB;
+/**
+ * Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`:
+ * `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`).
+ */
+const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst';
+/** The Zstandard frame magic number, little-endian on disk. */
+const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
+/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */
+const MAX_DIRECTORY_DEPTH = 8;
+
+const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i;
+const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+type Json = Record;
+
+export interface LocalHistoryOptions {
+ file?: string;
+ sessionId?: string;
+ rootSessionId?: string;
+ sessionsRoot?: string;
+ topic?: string;
+ /** Inclusive UTC date, `YYYY-MM-DD`. */
+ after?: string;
+ /** Inclusive UTC date, `YYYY-MM-DD`. */
+ before?: string;
+ scanBytes?: number;
+ metadataBytes?: number;
+ maxEntries?: number;
+}
+export interface HistoryRecord {
+ rootSessionId: string | null;
+ sessionId: string;
+ timestamp: string;
+ role: 'user' | 'assistant';
+ /** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */
+ phase: string | null;
+ message: string;
+}
+interface Source {
+ path: string;
+ sessionId: string;
+ parentId?: string;
+ forkedFromId?: string;
+ rootId?: string;
+ /** `subagent_history_start_ordinal`: records below it are inherited. */
+ historyStart?: number;
+ timestamp: string;
+ /** `dev:ino` of the attributed file. */
+ identity: string;
+ /** Size when attributed; a later read may only see it grow. */
+ size: number;
+}
+export interface LocalHistoryResult {
+ source: 'codex-jsonl';
+ complete: boolean;
+ sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>;
+ records: HistoryRecord[];
+ scan: {
+ /** Transcript bytes read (`--scan-bytes`). */
+ bytesRead: number;
+ /** Head bytes read to attribute rollouts (`--metadata-bytes`). */
+ metadataBytesRead: number;
+ entries: number;
+ malformedRecords: number;
+ partialRecords: number;
+ skippedSymlinks: number;
+ unattributedFiles: number;
+ /**
+ * Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`)
+ * this reader cannot open. Their threads are missing from the result.
+ */
+ compressedFiles: number;
+ unreadableDirectories: number;
+ /** Inherited fork or sub-agent prefixes dropped (informational). */
+ inheritedPrefixes: number;
+ /** Inherited prefixes that never reached their end marker. */
+ incompletePrefixes: number;
+ /** Legacy prefixes whose end could not be checked against the parent's own file. */
+ unverifiedPrefixes: number;
+ };
+}
+interface Budget {
+ transcriptRemaining: number;
+ metadataRemaining: number;
+ entriesRemaining: number;
+ scan: LocalHistoryResult['scan'];
+ /** Paths behind `scan.compressedFiles`, for the not-found error. */
+ compressedPaths: string[];
+}
+
+/** A budget ran out: always fatal, never counted. */
+class HistoryBudgetError extends Error {}
+/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */
+class UnattributableFileError extends Error {}
+
+function object(value: unknown): Json {
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {};
+}
+function string(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined;
+}
+function fullId(value: unknown): string | undefined {
+ const id = string(value);
+ return id && FULL_ID.test(id) ? id.toLowerCase() : undefined;
+}
+function fileIdentity(value: { dev: number; ino: number }): string {
+ return `${value.dev}:${value.ino}`;
+}
+function validDate(value: string | undefined): boolean {
+ return (
+ value === undefined ||
+ (/^\d{4}-\d{2}-\d{2}$/.test(value) &&
+ Number.isFinite(Date.parse(value)) &&
+ new Date(value).toISOString().slice(0, 10) === value)
+ );
+}
+/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */
+function utcDate(timestamp: string): string | undefined {
+ const ms = Date.parse(timestamp);
+ return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined;
+}
+function checkBudget(value: number, max: number, flag: string): void {
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`);
+}
+function validate(options: LocalHistoryOptions): void {
+ if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1)
+ throw new Error('Select exactly one of --file, --session-id, or --root-session-id');
+ if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required');
+ if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id');
+ if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId))
+ throw new Error('Invalid root session id');
+ if (
+ !validDate(options.after) ||
+ !validDate(options.before) ||
+ (options.after && options.before && options.after > options.before)
+ )
+ throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)');
+ if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic');
+ checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes');
+ checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes');
+ checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries');
+}
+async function regularFile(path: string): Promise {
+ // O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat
+ // validates the opened descriptor, not a previously inspected pathname.
+ if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink');
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
+ if (!(await handle.stat()).isFile()) {
+ await handle.close();
+ throw new Error('History source must be a regular file');
+ }
+ return handle;
+}
+async function readAt(handle: FileHandle, position: number, size: number): Promise {
+ const buffer = Buffer.alloc(size);
+ let filled = 0;
+ while (filled < size) {
+ const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled);
+ if (!bytesRead) break;
+ filled += bytesRead;
+ }
+ return buffer.subarray(0, filled);
+}
+async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) {
+ if (size > budget.metadataRemaining)
+ throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes');
+ const chunk = await readAt(handle, position, size);
+ budget.metadataRemaining -= chunk.length;
+ budget.scan.metadataBytesRead += chunk.length;
+ return chunk;
+}
+async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise {
+ if (size > budget.transcriptRemaining)
+ throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes');
+ const buffer = await readAt(handle, 0, size);
+ budget.transcriptRemaining -= buffer.length;
+ budget.scan.bytesRead += buffer.length;
+ return buffer;
+}
+
+type Identity = Omit;
+function metadata(value: unknown, path: string): Identity | undefined {
+ const entry = object(value);
+ if (entry.type !== 'session_meta') return undefined;
+ const p = object(entry.payload);
+ const sessionId = fullId(p.id) ?? fullId(p.session_id);
+ if (!sessionId) return undefined;
+ const spawn = object(object(object(p.source).subagent).thread_spawn);
+ const forkedFromId = fullId(p.forked_from_id);
+ // A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread.
+ const parentId =
+ fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId;
+ // Codex: "session_id is equal to the root thread's ID".
+ const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id);
+ const start = p.subagent_history_start_ordinal;
+ return {
+ path,
+ sessionId,
+ parentId,
+ forkedFromId,
+ rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined,
+ historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined,
+ timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '',
+ };
+}
+function sameIdentity(a: Identity, b: Identity): boolean {
+ return (
+ a.sessionId === b.sessionId &&
+ a.parentId === b.parentId &&
+ a.forkedFromId === b.forkedFromId &&
+ a.rootId === b.rootId &&
+ a.historyStart === b.historyStart
+ );
+}
+
+/**
+ * The first `session_meta` within the file's head. Problems with the file
+ * itself throw {@link UnattributableFileError}; a budget throws
+ * {@link HistoryBudgetError}; a short file with no metadata yet returns undefined.
+ */
+async function readMetadata(path: string, budget: Budget): Promise {
+ let handle: FileHandle;
+ try {
+ handle = await regularFile(path);
+ } catch (error) {
+ throw new UnattributableFileError(error instanceof Error ? error.message : String(error));
+ }
+ try {
+ const before = await handle.stat();
+ const limit = Math.min(before.size, METADATA_HEAD_BYTES);
+ let buffer = Buffer.alloc(0);
+ let parsed = 0;
+ while (buffer.length < limit) {
+ const chunk = await readMetadataChunk(
+ handle,
+ buffer.length,
+ Math.min(METADATA_CHUNK_BYTES, limit - buffer.length),
+ budget
+ );
+ if (!chunk.length) break;
+ if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
+ throw new UnattributableFileError(
+ 'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read'
+ );
+ buffer = Buffer.concat([buffer, chunk]);
+ let end = buffer.indexOf(10, parsed);
+ while (end >= 0) {
+ const line = buffer.subarray(parsed, end);
+ parsed = end + 1;
+ end = buffer.indexOf(10, parsed);
+ if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8');
+ let value: unknown;
+ try {
+ value = JSON.parse(line.toString('utf8'));
+ } catch {
+ continue;
+ }
+ const found = metadata(value, path);
+ if (!found) continue;
+ // session_meta is written once; a growing file is a live rollout, not a change.
+ const after = await handle.stat();
+ if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size)
+ throw new UnattributableFileError('History source changed during metadata scan');
+ return { ...found, identity: fileIdentity(before), size: before.size };
+ }
+ }
+ if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan');
+ if (before.size > METADATA_HEAD_BYTES)
+ throw new UnattributableFileError('No session metadata within the first 128 KiB');
+ return undefined;
+ } finally {
+ await handle.close();
+ }
+}
+
+async function findSources(root: string, budget: Budget): Promise {
+ // The root may itself be a symlink, as the Past Sessions scanner allows
+ // (`codex-transcript.ts` reads it with readdir, which follows it); links
+ // BELOW the root are never followed by either reader.
+ let rootStat;
+ try {
+ rootStat = await stat(root);
+ } catch {
+ throw new Error(`Sessions root not found: ${root}`);
+ }
+ if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory');
+ const sources: Source[] = [];
+ const visit = async (dir: string, depth: number): Promise => {
+ if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted');
+ let entries;
+ try {
+ entries = await opendir(dir);
+ } catch {
+ budget.scan.unreadableDirectories++;
+ return;
+ }
+ for await (const entry of entries) {
+ if (--budget.entriesRemaining < 0)
+ throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
+ budget.scan.entries++;
+ const path = join(dir, entry.name);
+ if (entry.isSymbolicLink()) {
+ budget.scan.skippedSymlinks++;
+ continue;
+ }
+ if (entry.isDirectory()) {
+ // Recheck immediately before descending; never intentionally follow links.
+ const now = await lstat(path).catch(() => undefined);
+ if (!now?.isDirectory()) {
+ budget.scan.unreadableDirectories++;
+ continue;
+ }
+ await visit(path, depth + 1);
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
+ let source: Source | undefined;
+ try {
+ source = await readMetadata(path, budget);
+ } catch (error) {
+ if (!(error instanceof UnattributableFileError)) throw error;
+ }
+ if (source) sources.push(source);
+ else budget.scan.unattributedFiles++;
+ } else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) {
+ // Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`),
+ // and that sibling is visited (or counted) on its own.
+ const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined);
+ if (plain) continue;
+ budget.scan.compressedFiles++;
+ budget.compressedPaths.push(path);
+ }
+ }
+ };
+ await visit(root, 0);
+ return sources;
+}
+
+interface Message {
+ representation: 'event' | 'modern' | 'response';
+ role: 'user' | 'assistant';
+ message: string;
+ phase: string | null;
+ timestamp: string;
+}
+function message(value: unknown): Message | undefined {
+ const entry = object(value);
+ const p = object(entry.payload);
+ if (entry.isSidechain || p.isSidechain) return undefined;
+ const found = codexMessageFromLine(value);
+ if (!found) return undefined;
+ // Codex context is not a human turn. Real prompts also arrive as events, which are never filtered.
+ if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined;
+ return {
+ role: found.role,
+ message: found.text,
+ // Codex records a phase on assistant messages only; a user turn has none.
+ phase: found.role === 'assistant' ? (found.phase ?? null) : null,
+ representation: found.shape === 'item' ? 'modern' : found.shape,
+ timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '',
+ };
+}
+/** Native events and response_item commonly duplicate one accepted message. */
+function collapse(items: Message[]): Message[] {
+ const records: Message[] = [];
+ let representations = new Set();
+ for (const raw of items) {
+ const item = { ...raw };
+ const previous = records.at(-1);
+ if (
+ previous?.role === item.role &&
+ previous.message === item.message &&
+ !representations.has(item.representation)
+ ) {
+ representations.add(item.representation);
+ if (!previous.timestamp) previous.timestamp = item.timestamp;
+ if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase;
+ continue;
+ }
+ records.push(item);
+ representations = new Set([item.representation]);
+ }
+ return records;
+}
+function isPrefixOf(prefix: Message[], whole: Message[]): boolean {
+ return (
+ prefix.length <= whole.length &&
+ prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message)
+ );
+}
+
+interface ReadContext {
+ /**
+ * The ancestor's own messages, uncut, or undefined when its file is not in the
+ * store or could not be read for any reason other than a budget.
+ */
+ ancestorMessages(id: string): Promise;
+}
+type Piece = Message | { boundary: true };
+interface LegacyPrefix {
+ ancestorId: string;
+ /** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */
+ pieces: Piece[];
+}
+/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */
+interface ParsedSource {
+ /** Every message in file order, inherited copies included: what a child's copy of this thread holds. */
+ raw: Message[];
+ /** Messages before any legacy inherited prefix, paginated inherited records already dropped. */
+ own: Message[];
+ legacy?: LegacyPrefix;
+}
+
+async function parseSource(source: Source, budget: Budget): Promise {
+ const handle = await regularFile(source.path);
+ let buffer: Buffer;
+ try {
+ const now = await handle.stat();
+ // A live rollout only grows. A different file or a shorter one is a rewrite.
+ if (fileIdentity(now) !== source.identity || now.size < source.size)
+ throw new Error('History source changed after attribution; retry explicitly');
+ buffer = await readTranscript(handle, now.size, budget);
+ if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly');
+ } finally {
+ await handle.close();
+ }
+ // Snapshot up to the last complete line: a running Codex may be mid-append.
+ const end = buffer.lastIndexOf(10) + 1;
+ const complete = buffer.subarray(0, end);
+ if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++;
+ if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8');
+
+ const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id));
+ const raw: Message[] = [];
+ const own: Message[] = [];
+ let legacy: LegacyPrefix | undefined;
+ let lastOrdinal = -1;
+ for (const line of complete.toString('utf8').split('\n')) {
+ if (!line.trim()) continue;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ budget.scan.malformedRecords++;
+ continue;
+ }
+ const entry = object(parsed);
+ const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined;
+ if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal);
+ if (source.historyStart !== undefined && ordinal === undefined) {
+ // Codex's projection skips a paginated record without an ordinal; so does this, visibly.
+ budget.scan.malformedRecords++;
+ continue;
+ }
+ const inherited = source.historyStart !== undefined && ordinal! < source.historyStart;
+ if (entry.type === 'session_meta') {
+ const declared = metadata(parsed, source.path);
+ if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue;
+ if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId))
+ throw new Error('History source contains conflicting native metadata; attribution refused');
+ // An ancestor that was itself a fork carries its own ancestors' metadata in the copy.
+ for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id);
+ if (inherited || legacy) continue;
+ budget.scan.inheritedPrefixes++;
+ legacy = { ancestorId: declared.sessionId, pieces: [] };
+ continue;
+ }
+ if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
+ if (!inherited) legacy.pieces.push({ boundary: true });
+ continue;
+ }
+ const item = message(parsed);
+ if (!item) continue;
+ raw.push(item);
+ if (inherited) continue;
+ if (legacy) legacy.pieces.push(item);
+ else own.push(item);
+ }
+ if (source.historyStart !== undefined && source.historyStart > 0) {
+ budget.scan.inheritedPrefixes++;
+ // Initialization died while copying the prefix: Codex refuses to resume such a file.
+ if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++;
+ }
+ return { raw: collapse(raw), own, legacy };
+}
+
+/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */
+async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise {
+ const own = [...parsed.own];
+ if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context)));
+ return collapse(own);
+}
+
+/** The child's own messages after a legacy inherited prefix. */
+async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise {
+ const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : []));
+ if (!boundaries.length) {
+ budget.scan.incompletePrefixes++;
+ return [];
+ }
+ const messagesBefore = (index: number) =>
+ collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece)));
+ let cut = boundaries[0];
+ if (boundaries.length > 1) {
+ // The last candidate whose preceding messages the parent's own file also holds.
+ const parent = await context.ancestorMessages(prefix.ancestorId);
+ const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : [];
+ if (proven.length) cut = proven.at(-1)!;
+ else {
+ // Unprovable: drop up to the last candidate, so parent text is never attributed to the child.
+ budget.scan.unverifiedPrefixes++;
+ cut = boundaries.at(-1)!;
+ }
+ }
+ return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece));
+}
+
+function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] {
+ const turns: Message[][] = [];
+ for (const item of messages) {
+ if (item.role === 'user' || !turns.length) turns.push([]);
+ turns.at(-1)!.push(item);
+ }
+ const needle = options.topic?.trim().toLocaleLowerCase();
+ return turns
+ .filter((turn) => {
+ const dateMatches =
+ (!options.after && !options.before) ||
+ turn.some((item) => {
+ const date = utcDate(item.timestamp);
+ return (
+ date !== undefined &&
+ (!options.after || date >= options.after) &&
+ (!options.before || date <= options.before)
+ );
+ });
+ return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle)));
+ })
+ .flat();
+}
+
+/** Native ancestry is metadata evidence, not the conversation selected by a query. */
+function nativeRoot(source: Source, sources: Source[]): string | null {
+ const seen = new Set();
+ let current = source;
+ while (true) {
+ if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry');
+ seen.add(current.sessionId);
+ if (current.rootId) return current.rootId;
+ if (!current.parentId) return current.sessionId;
+ const parents = sources.filter((candidate) => candidate.sessionId === current.parentId);
+ if (parents.length === 0) return null;
+ if (parents.length > 1) throw new Error('Native ancestor id is ambiguous');
+ current = parents[0];
+ }
+}
+
+/**
+ * The not-found error, naming what the store held but could not attribute.
+ * Codex names a rollout `rollout--.jsonl[.zst]`, so a
+ * compressed file whose name carries the wanted id is named outright; the name
+ * is only a hint here, never identity.
+ */
+function missingSessionMessage(wanted: string, budget: Budget): string {
+ const { scan } = budget;
+ const notes: string[] = [];
+ if (scan.compressedFiles) {
+ // `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id.
+ const named = budget.compressedPaths.filter((path) => {
+ const name = basename(path).toLowerCase();
+ return name.startsWith('rollout-') && name.slice(28).startsWith(wanted);
+ });
+ const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : '');
+ notes.push(
+ named.length
+ ? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc > copy.jsonl)`
+ : `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read`
+ );
+ }
+ if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`);
+ return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`;
+}
+
+export async function readLocalHistory(options: LocalHistoryOptions): Promise {
+ validate(options);
+ const scan: LocalHistoryResult['scan'] = {
+ bytesRead: 0,
+ metadataBytesRead: 0,
+ entries: 0,
+ malformedRecords: 0,
+ partialRecords: 0,
+ skippedSymlinks: 0,
+ unattributedFiles: 0,
+ compressedFiles: 0,
+ unreadableDirectories: 0,
+ inheritedPrefixes: 0,
+ incompletePrefixes: 0,
+ unverifiedPrefixes: 0,
+ };
+ const budget: Budget = {
+ transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
+ metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
+ entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
+ scan,
+ compressedPaths: [],
+ };
+ let sources: Source[];
+ let attributionSources: Source[];
+ if (options.file !== undefined) {
+ let source: Source | undefined;
+ try {
+ source = await readMetadata(resolve(options.file), budget);
+ } catch (error) {
+ throw error instanceof UnattributableFileError ? new Error(error.message) : error;
+ }
+ if (!source) throw new Error('History source is missing valid native session metadata');
+ sources = [source];
+ attributionSources = sources;
+ } else {
+ const root = options.sessionsRoot ?? codexSessionsRoot();
+ const all = await findSources(resolve(root), budget);
+ const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase();
+ const matches = all.filter((source) => source.sessionId.startsWith(wanted));
+ if (!matches.length) throw new Error(missingSessionMessage(wanted, budget));
+ if (matches.length !== 1) throw new Error('Native session id is ambiguous');
+ const target = matches[0];
+ attributionSources = all;
+ const queryRootId = target.sessionId;
+ sources = [target];
+ if (options.rootSessionId) {
+ const selected = new Set([target.sessionId]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const source of all) {
+ if (
+ !selected.has(source.sessionId) &&
+ (source.rootId === queryRootId || (source.parentId && selected.has(source.parentId)))
+ ) {
+ selected.add(source.sessionId);
+ sources.push(source);
+ changed = true;
+ }
+ }
+ }
+ for (const source of sources) {
+ if (all.filter((other) => other.sessionId === source.sessionId).length !== 1)
+ throw new Error('Native family contains ambiguous duplicated session ids');
+ }
+ }
+ }
+ sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId));
+ const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)]));
+ // Each selected file is read once, whether the main loop or a child's boundary proof asks first.
+ const parsedCache = new Map>();
+ const parse = (source: Source) => {
+ let parsed = parsedCache.get(source);
+ if (!parsed) {
+ parsed = parseSource(source, budget);
+ parsedCache.set(source, parsed);
+ }
+ return parsed;
+ };
+ const context: ReadContext = {
+ // Raw messages need no boundary proof of their own, so this never recurses.
+ async ancestorMessages(id) {
+ const candidates = attributionSources.filter((source) => source.sessionId === id);
+ if (candidates.length !== 1) return undefined;
+ try {
+ return (await parse(candidates[0])).raw;
+ } catch (error) {
+ // A budget always fails the read; any other failure only leaves the boundary unproven.
+ if (error instanceof HistoryBudgetError) throw error;
+ return undefined;
+ }
+ },
+ };
+ const records: HistoryRecord[] = [];
+ for (const source of sources) {
+ records.push(
+ ...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({
+ rootSessionId: roots.get(source.sessionId) ?? null,
+ sessionId: source.sessionId,
+ role: item.role,
+ message: item.message,
+ phase: item.phase,
+ timestamp: item.timestamp,
+ }))
+ );
+ }
+ if (!records.length) throw new Error('No matching user/assistant evidence found');
+ return {
+ source: 'codex-jsonl',
+ complete:
+ scan.malformedRecords === 0 &&
+ scan.partialRecords === 0 &&
+ scan.skippedSymlinks === 0 &&
+ scan.unattributedFiles === 0 &&
+ scan.compressedFiles === 0 &&
+ scan.unreadableDirectories === 0 &&
+ scan.incompletePrefixes === 0 &&
+ scan.unverifiedPrefixes === 0 &&
+ [...roots.values()].every((root) => root !== null),
+ sessions: sources.map((source) => ({
+ sessionId: source.sessionId,
+ rootSessionId: roots.get(source.sessionId) ?? null,
+ ...(source.parentId ? { parentSessionId: source.parentId } : {}),
+ path: source.path,
+ })),
+ records,
+ scan,
+ };
+}
+/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
+export function validateOutputBytes(maxBytes: number): void {
+ checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
+}
+/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
+export function formatLocalHistory(
+ result: LocalHistoryResult,
+ format: 'json' | 'text' = 'json',
+ maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES
+): string {
+ validateOutputBytes(maxBytes);
+ const body =
+ format === 'json'
+ ? JSON.stringify(result)
+ : [
+ `source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`,
+ ...result.records.map(
+ (row) =>
+ `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}`
+ ),
+ ].join('\n\n');
+ if (Buffer.byteLength(body) + 1 > maxBytes)
+ throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes');
+ return body;
+}
diff --git a/src/mcp-server.ts b/src/mcp-server.ts
index c79b2b4ae..7d9a1b810 100644
--- a/src/mcp-server.ts
+++ b/src/mcp-server.ts
@@ -38,6 +38,7 @@ import {
buildSendBody,
composerReadyMark,
describeFailure,
+ describeRequestError,
httpRequest,
inputRefusal,
isSelfSession,
@@ -497,7 +498,7 @@ export function createToolRunner(deps: ToolRunnerDeps) {
} catch (err) {
if (signal?.aborted) throw err;
throw new ToolError(
- `Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` +
+ `Cannot reach Codeman at ${ctx.apiUrl}: ${describeRequestError(err)}. Check that it is running. If every call ` +
'fails at once, a sandbox around this MCP server may be blocking network access to this machine.'
);
}
diff --git a/src/mux-interface.ts b/src/mux-interface.ts
index 0f37758fe..57fa360e0 100644
--- a/src/mux-interface.ts
+++ b/src/mux-interface.ts
@@ -314,9 +314,10 @@ export interface TerminalMultiplexer extends EventEmitter {
// ========== Input ==========
/**
- * Send input to a session via tmux send-keys.
+ * Send input to a session via tmux send-keys. The optional internal Enter-only
+ * guard runs synchronously immediately before transport dispatch.
*/
- sendInput(sessionId: string, input: string): Promise;
+ sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise;
// ========== Metadata ==========
diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts
index 2dd5e754e..40080c343 100644
--- a/src/prompt-cache-keepwarm.ts
+++ b/src/prompt-cache-keepwarm.ts
@@ -338,9 +338,11 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
try {
settings = await this.readSettings();
} catch (err) {
- // A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
- // with a trailing comma) fails every read until it is fixed, so it is logged once rather
- // than once a minute per session, and only a session that could nudge retries.
+ // A failed read is retried (settings.json is replaced atomically, so a read never sees a
+ // half-written file; a failure is a read error or a file that does not parse). A settings.json
+ // that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so
+ // it is logged once rather than once a minute per session, and only a session that could
+ // nudge retries.
const message = err instanceof Error ? err.message : String(err);
if (message !== this.settingsFailure) {
this.settingsFailure = message;
diff --git a/src/session.ts b/src/session.ts
index 1cb18fe9b..a2b495656 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -68,6 +68,7 @@ import {
type StatusLineExporter,
type KeepWarmStatus,
} from './types.js';
+import type { GitFetchStatus } from './git-fetch.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
@@ -129,6 +130,14 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { SubmitVerifier } from './session-submit-verifier.js';
+import {
+ guardedComposerChrome,
+ promptSha256,
+ readGuardedSnapshotDetails,
+ type GuardedInputSnapshot,
+} from './agent-staging.js';
+import { isKeyboardTerminalInput } from './terminal-input-classifier.js';
+import { stripAnsi } from './utils/regex-patterns.js';
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
@@ -457,6 +466,17 @@ export interface ClaudeMessage {
duration_ms?: number;
}
+/**
+ * A write that a guarded submit's lease could not hold, because the per-lease hold
+ * queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it.
+ */
+export class GuardedInputBusyError extends Error {
+ constructor() {
+ super('A guarded submit is in progress on this session; nothing was written. Retry the input.');
+ this.name = 'GuardedInputBusyError';
+ }
+}
+
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
@@ -614,6 +634,12 @@ export class Session extends EventEmitter {
private _keepWarmEnabled: boolean | undefined;
/** Last status the keep-warm service published for this session; ephemeral. */
private _keepWarm: KeepWarmStatus | null = null;
+ /** Git fetch on an interval for this session; undefined = follow the app-wide default. Persisted. */
+ private _gitFetchEnabled: boolean | undefined;
+ /** Seconds between this session's fetch runs; undefined = follow the app-wide default. Persisted. */
+ private _gitFetchIntervalSeconds: number | undefined;
+ /** Last status the fetch scheduler published for this session; ephemeral. */
+ private _gitFetch: GitFetchStatus | null = null;
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
private _keepAliveTurnUntil = 0;
/** Last keystroke a person sent through a `fromUser` write. */
@@ -926,6 +952,10 @@ export class Session extends EventEmitter {
lastRealPromptAt?: number;
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
keepWarmEnabled?: boolean;
+ /** Restored Git fetch override for this session (see `gitFetchEnabled`). */
+ gitFetchEnabled?: boolean;
+ /** Restored Git fetch interval for this session (see `gitFetchIntervalSeconds`). */
+ gitFetchIntervalSeconds?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
@@ -1015,6 +1045,10 @@ export class Session extends EventEmitter {
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
this._keepWarmEnabled = config.keepWarmEnabled;
+ // Restored, like the keep-warm choice: the interval is the user's decision about their repositories
+ // and outlives the run that made it, so a restart keeps it rather than falling back to the app default.
+ this._gitFetchEnabled = config.gitFetchEnabled;
+ this._gitFetchIntervalSeconds = config.gitFetchIntervalSeconds;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
@@ -1308,6 +1342,7 @@ export class Session extends EventEmitter {
/** Set the owning username (used by recovery to restore ownership). */
set owner(username: string | undefined) {
+ if (this._owner !== username) this._guardedPaneRevision++;
this._owner = username;
}
@@ -1412,11 +1447,13 @@ export class Session extends EventEmitter {
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
*/
markClosing(closing: boolean): void {
+ if (this._closing !== closing) this._guardedPaneRevision++;
this._closing = closing;
}
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
private async _withPaneLifecycle(op: () => Promise): Promise {
+ this._guardedPaneRevision++;
this._paneLifecycleOps++;
try {
return await op();
@@ -1443,6 +1480,7 @@ export class Session extends EventEmitter {
* Ark0N/Codeman#446 review; both now persist on their success path.
*/
private clearPaneExitForNewPane(): void {
+ this._guardedPaneRevision++;
this.setPaneExit(undefined);
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
@@ -1579,6 +1617,37 @@ export class Session extends EventEmitter {
return changed;
}
+ /** Git fetch override for this session; undefined means the app-wide setting applies. */
+ get gitFetchEnabled(): boolean | undefined {
+ return this._gitFetchEnabled;
+ }
+
+ /** Interval override for this session; undefined means the app-wide setting applies. */
+ get gitFetchIntervalSeconds(): number | undefined {
+ return this._gitFetchIntervalSeconds;
+ }
+
+ /**
+ * Set the session's override. The Session Options rows save on their own, so a caller passes what it has
+ * and leaves the other half as it was; `undefined` on either means the app-wide setting applies again.
+ */
+ setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
+ this._gitFetchEnabled = enabled;
+ this._gitFetchIntervalSeconds = intervalSeconds;
+ }
+
+ /** What the fetch scheduler last reported for this session, or null before its first run. */
+ get gitFetch(): GitFetchStatus | null {
+ return this._gitFetch;
+ }
+
+ /** Replace the fetch status; true when it differs from the previous one. */
+ setGitFetchStatus(status: GitFetchStatus): boolean {
+ const changed = JSON.stringify(status) !== JSON.stringify(this._gitFetch);
+ this._gitFetch = status;
+ return changed;
+ }
+
/**
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
@@ -2116,6 +2185,9 @@ export class Session extends EventEmitter {
statusLineExporter: this.statusLineExporter,
keepWarmEnabled: this._keepWarmEnabled,
keepWarm: this._keepWarm ?? undefined,
+ gitFetchEnabled: this._gitFetchEnabled,
+ gitFetchIntervalSeconds: this._gitFetchIntervalSeconds,
+ gitFetch: this._gitFetch ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
@@ -4206,6 +4278,11 @@ export class Session extends EventEmitter {
prompt: string,
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
): Promise<{ result: string; cost: number }> {
+ if (this.guardedInputHeld) {
+ this._guardedInputRevision++;
+ throw new Error('Guarded submission in progress');
+ }
+ this._guardedPaneRevision++;
return new Promise((resolve, reject) => {
if (this.ptyProcess) {
reject(new Error('Session already has a running process'));
@@ -4715,6 +4792,229 @@ export class Session extends EventEmitter {
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
+ private readonly _inspectionInstance = uuidv4();
+ private _guardedPaneRevision = 0;
+ private _guardedGeometryRevision = 0;
+ private _guardedInputRevision = 0;
+ private _guardedPendingWrites = 0;
+ private _guardedSubmitInFlight = false;
+ /**
+ * Input that arrived while a guarded submit held the lease, in arrival order. Each
+ * entry runs when the lease is released; an entry that returns a promise (a mux
+ * write, a named key) is waited for before the next runs, so nothing overtakes it.
+ */
+ private _guardedHeld: Array<{ run: () => void | Promise; bytes: number }> = [];
+ private _guardedHeldBytes = 0;
+ /** True while the drain waits for a held asynchronous write to finish. */
+ private _guardedHeldDraining = false;
+ /** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */
+ static readonly MAX_GUARDED_HELD_INPUTS = 256;
+ /** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */
+ static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024;
+ /** Ephemeral identity, never persisted or based on a potentially reused pid alone. */
+ get inspectionGeneration(): string {
+ return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`;
+ }
+ /** Staging additionally binds geometry; transcript cursors survive a terminal resize. */
+ get guardedInputGeneration(): string {
+ return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`;
+ }
+ get guardedInputPendingWrites(): number {
+ return this._guardedPendingWrites;
+ }
+ /**
+ * True while a guarded submit holds the input lease (a few milliseconds). Input that
+ * arrives in that span is held and written after it (see {@link guardedInputHeld}).
+ */
+ get guardedSubmitInFlight(): boolean {
+ return this._guardedSubmitInFlight;
+ }
+ /**
+ * True while input is being held behind a guarded submit: the lease itself, or held
+ * input not yet written after it. A write that is not routed through
+ * {@link holdForGuardedLease} must refuse in this window, or it overtakes held input.
+ * `write()` is the known exception: it refuses during the lease only, since the drain
+ * writes held input through it, so a direct `write()` caller (cron's `paste` mode in
+ * `cron/cron-service.ts`) can still land ahead of input that is draining.
+ */
+ get guardedInputHeld(): boolean {
+ return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining;
+ }
+ /**
+ * Hold input behind a guarded submit's lease instead of refusing it.
+ *
+ * `free`: nothing is held, so the caller writes inline as it always did. `held`: `run`
+ * is queued and runs, in arrival order with every other held input, once the lease is
+ * released (whatever the submit's outcome). `full`: the per-lease cap is reached and
+ * nothing was queued; the caller must stop delivering from that source until it can
+ * resend in order. Refusing instead lost input: the browser's WebSocket path does not
+ * wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then
+ * rejected as a duplicate. `data`, when given, is observed as input on arrival, so a
+ * keystroke held during the lease still moves the input revision at once.
+ */
+ holdForGuardedLease(run: () => void | Promise, bytes = 0, data?: string): 'free' | 'held' | 'full' {
+ if (!this.guardedInputHeld) return 'free';
+ if (data !== undefined) this._observeGuardedKeyboardInput(data);
+ if (
+ this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS ||
+ this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES
+ )
+ return 'full';
+ this._guardedHeld.push({ run, bytes });
+ this._guardedHeldBytes += bytes;
+ return 'held';
+ }
+ /**
+ * Run an asynchronous write now, or in its turn behind a guarded submit's lease. The
+ * turn lasts until the write settles, so later held input waits for it. Resolves
+ * `whenFull` without running anything when the hold queue is full.
+ */
+ private _inGuardedTurn(write: () => Promise, whenFull: () => T): Promise {
+ if (!this.guardedInputHeld) return write();
+ return new Promise((resolve, reject) => {
+ const held = this.holdForGuardedLease(() => {
+ const running = write();
+ running.then(resolve, reject);
+ return running;
+ });
+ if (held === 'full') {
+ try {
+ resolve(whenFull());
+ } catch (err) {
+ reject(err);
+ }
+ }
+ });
+ }
+ /** Run held input in arrival order until it is all written or a new lease is taken. */
+ private _drainGuardedHeld(): void {
+ while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) {
+ const entry = this._guardedHeld.shift()!;
+ this._guardedHeldBytes -= entry.bytes;
+ let running: void | Promise;
+ try {
+ running = entry.run();
+ } catch (err) {
+ console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err));
+ continue;
+ }
+ if (running && typeof running.then === 'function') {
+ this._guardedHeldDraining = true;
+ void running
+ .catch(() => {})
+ .then(() => {
+ this._guardedHeldDraining = false;
+ this._drainGuardedHeld();
+ });
+ }
+ }
+ }
+ /** Expose only the path selection needed for attributed Codex evidence, never credentials. */
+ getCodexHomeOverride(): string | undefined {
+ return this._envOverrides?.CODEX_HOME || undefined;
+ }
+ private _observeGuardedKeyboardInput(data: string): void {
+ if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++;
+ }
+ guardedInputSnapshot(): GuardedInputSnapshot | null {
+ return this._readGuardedInputSnapshot(false);
+ }
+ private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null {
+ if (
+ this._isStopped ||
+ this._closing ||
+ this.paneLifecycleInFlight ||
+ this._guardedPendingWrites ||
+ (!allowLease && this.guardedInputHeld) ||
+ this.remote ||
+ this.docker ||
+ this.paneExit ||
+ !this._muxSession
+ )
+ return null;
+ // Only a CLI whose registry entry describes its composer chrome takes guarded input.
+ const chrome = guardedComposerChrome(getCli(this.mode));
+ if (!chrome) return null;
+ const capture: import('./mux-interface.js').PaneCaptureOptions = {};
+ const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture);
+ if (!screen) return null;
+ // tmux's replay has CUP addresses rather than line breaks. Read the SAME
+ // fresh frame for dialog/work chrome and exact composer/caret proof.
+ // eslint-disable-next-line no-control-regex
+ const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n'));
+ const working = this._workingLinePattern();
+ working.lastIndex = 0;
+ if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null;
+ const composer = readGuardedSnapshotDetails(screen, chrome);
+ return composer === null
+ ? null
+ : {
+ generation: this.guardedInputGeneration,
+ revision: this._guardedInputRevision,
+ composerSha: promptSha256(composer.composer),
+ availableColumns: Number.isFinite(capture.capturedGeometry?.cols)
+ ? capture.capturedGeometry!.cols - composer.prefixWidth
+ : 0,
+ };
+ }
+ /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */
+ async stageGuardedInput(message: string): Promise {
+ if (/[\r\n]/.test(message)) return false;
+ if (this._guardedPendingWrites || this.guardedInputHeld) {
+ this._guardedInputRevision++;
+ return false;
+ }
+ this._submitVerifier?.cancel();
+ return this.writeViaMux(message, { fromUser: true });
+ }
+ /** One conditional Enter. An ambiguous acknowledgement is never replayed. */
+ async submitGuardedInput(expected: GuardedInputSnapshot): Promise {
+ const matches = (current: GuardedInputSnapshot | null): boolean =>
+ !!current &&
+ current.generation === expected.generation &&
+ current.revision === expected.revision &&
+ current.composerSha === expected.composerSha &&
+ current.availableColumns === expected.availableColumns;
+ if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false;
+ this._guardedSubmitInFlight = true;
+ let dispatched = false;
+ try {
+ const sent = await this._mux.sendInput(this.id, '\r', {
+ beforeDispatch: () => {
+ if (!matches(this._readGuardedInputSnapshot(true))) return false;
+ this._guardedInputRevision++;
+ dispatched = true;
+ return true;
+ },
+ });
+ if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true }));
+ return sent && dispatched;
+ } finally {
+ this._guardedSubmitInFlight = false;
+ this._drainGuardedHeld();
+ }
+ }
+ /**
+ * Lease/pending boundary for named keys whose bytes bypass the ordinary write methods.
+ * During a guarded submit the operation waits for the lease like any other write;
+ * only a full hold queue refuses it, with {@link GuardedInputBusyError}.
+ */
+ async performAuxiliaryInput(operation: () => Promise): Promise {
+ this._guardedInputRevision++;
+ return this._inGuardedTurn(
+ async () => {
+ this._guardedPendingWrites++;
+ try {
+ await operation();
+ } finally {
+ this._guardedPendingWrites--;
+ }
+ },
+ () => {
+ throw new GuardedInputBusyError();
+ }
+ );
+ }
/**
* Sends input directly to the PTY process.
*
@@ -4734,6 +5034,9 @@ export class Session extends EventEmitter {
* input could disappear while the caller believed it had been delivered.
*/
write(data: string, options: SessionWriteOptions = {}): boolean {
+ this._observeGuardedKeyboardInput(data);
+ // The lease only, not the drain, which writes held input through here (see guardedInputHeld).
+ if (this._guardedSubmitInFlight) return false;
const submittedPrompt = this._trackSubmit(data, options);
if (!this.ptyProcess) return false;
this.ptyProcess.write(data);
@@ -4782,6 +5085,7 @@ export class Session extends EventEmitter {
* separator. Reports submissions like a write would (a line feed never is one).
*/
trackUserInput(data: string): void {
+ this._observeGuardedKeyboardInput(data);
if (!this._acceptsPrompts) return;
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
}
@@ -4803,6 +5107,7 @@ export class Session extends EventEmitter {
* their prompt must count as real and alert normally.
*/
markPromptSubmitted(prompt?: string): void {
+ this._guardedInputRevision++;
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
this._stampSubmit();
}
@@ -4898,22 +5203,39 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise {
- const submittedPrompt = this._trackSubmit(data, options);
- if (this._mux && this._muxSession) {
- const sent = await this._mux.sendInput(this.id, data);
- if (sent) {
+ this._observeGuardedKeyboardInput(data);
+ // A guarded submit's lease lasts for its Enter only: wait for it, in arrival order,
+ // rather than refusing, so an approval answer or a programmatic prompt is not
+ // dropped. Without a lease the write starts synchronously, so the pending count
+ // rises before any await and no lease can be taken while it is in flight.
+ return this._inGuardedTurn(
+ () => this._writeViaMuxNow(data, options),
+ () => false
+ );
+ }
+
+ private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise {
+ this._guardedPendingWrites++;
+ try {
+ const submittedPrompt = this._trackSubmit(data, options);
+ if (this._mux && this._muxSession) {
+ const sent = await this._mux.sendInput(this.id, data);
+ if (sent) {
+ this._emitSubmittedPrompt(submittedPrompt);
+ this._verifySubmitted(data);
+ }
+ return sent;
+ }
+ // Fallback to PTY write
+ if (this.ptyProcess) {
+ this.ptyProcess.write(data);
this._emitSubmittedPrompt(submittedPrompt);
- this._verifySubmitted(data);
+ return true;
}
- return sent;
- }
- // Fallback to PTY write
- if (this.ptyProcess) {
- this.ptyProcess.write(data);
- this._emitSubmittedPrompt(submittedPrompt);
- return true;
+ return false;
+ } finally {
+ this._guardedPendingWrites--;
}
- return false;
}
/**
@@ -4941,7 +5263,16 @@ export class Session extends EventEmitter {
this._isStopped || !this._mux || !this._muxSession
? null
: this._mux.capturePaneText?.(this._muxSession.muxName),
- sendEnter: () => this._mux?.sendInput(this.id, '\r'),
+ sendEnter: () => {
+ // A retry Enter is never held behind a guarded submit: once that submit's
+ // Enter went out, a re-press would submit whatever is on the composer next.
+ if (this.guardedInputHeld) {
+ this._guardedInputRevision++;
+ this._submitVerifier?.cancel();
+ return Promise.resolve(false);
+ }
+ return this.writeViaMux('\r');
+ },
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
// Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
@@ -4970,6 +5301,7 @@ export class Session extends EventEmitter {
* `resize()` skipped a real resize that happened to match them.
*/
private _notePtySpawnGeometry(cols: number, rows: number): void {
+ this._guardedPaneRevision++;
this._ptyCols = cols;
this._ptyRows = rows;
}
@@ -5092,6 +5424,7 @@ export class Session extends EventEmitter {
}
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
+ this._guardedGeometryRevision++;
this._ptyCols = cols;
this._ptyRows = rows;
if (!IS_TEST_MODE && this._mux && this._muxSession) {
@@ -5108,6 +5441,10 @@ export class Session extends EventEmitter {
// Legacy method for sending input - wraps runPrompt
async sendInput(input: string): Promise {
+ if (this.guardedInputHeld) {
+ this._guardedInputRevision++;
+ throw new Error('Guarded submission in progress');
+ }
this._status = 'busy';
this._markActivity(true);
this.runPrompt(input).catch((err) => {
diff --git a/src/terminal-input-classifier.ts b/src/terminal-input-classifier.ts
new file mode 100644
index 000000000..34ea2e85a
--- /dev/null
+++ b/src/terminal-input-classifier.ts
@@ -0,0 +1,62 @@
+/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */
+const FOCUS_REPORT_LENGTH = 3;
+const X10_REPORT_LENGTH = 6;
+
+function sgrMouseReportLength(rest: string): number {
+ if (!rest.startsWith('\x1b[<')) return 0;
+
+ let offset = 3;
+ for (let field = 0; field < 3; field += 1) {
+ const digitStart = offset;
+ while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) {
+ offset += 1;
+ }
+ if (offset === digitStart) return 0;
+
+ if (field < 2) {
+ if (rest[offset] !== ';') return 0;
+ offset += 1;
+ }
+ }
+
+ return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0;
+}
+
+function x10MouseReportLength(rest: string): number {
+ if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0;
+
+ const button = rest.charCodeAt(3);
+ const x = rest.charCodeAt(4);
+ const y = rest.charCodeAt(5);
+ if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0;
+
+ return X10_REPORT_LENGTH;
+}
+
+function terminalReportLengthAt(data: string, offset: number): number {
+ const rest = data.slice(offset);
+ if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH;
+
+ const sgrLength = sgrMouseReportLength(rest);
+ if (sgrLength > 0) return sgrLength;
+
+ return x10MouseReportLength(rest);
+}
+
+/**
+ * Whether terminal input contains any keyboard-bearing bytes.
+ *
+ * Returns false only when every byte is consumed as an allowlisted mouse or
+ * focus report. Unknown, malformed, mixed, and empty input fails closed.
+ */
+export function isKeyboardTerminalInput(data: string): boolean {
+ if (data.length === 0) return true;
+
+ let offset = 0;
+ while (offset < data.length) {
+ const reportLength = terminalReportLengthAt(data, offset);
+ if (reportLength === 0) return true;
+ offset += reportLength;
+ }
+ return false;
+}
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 158888502..f6a5efaf6 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -984,6 +984,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string {
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
}
+/**
+ * Split the output of `capture-pane -p \; display-message -p ''`
+ * into the captured rows and the cursor line, which tmux prints last. Null unless
+ * that last line has the cursor query's shape (four leading integers), so a query
+ * that printed nothing can never have the frame's own last row taken for it.
+ */
+export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null {
+ const body = output.endsWith('\n') ? output.slice(0, -1) : output;
+ const split = body.lastIndexOf('\n');
+ const cursor = body.slice(split + 1);
+ if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null;
+ return { frame: body.slice(0, split + 1), cursor };
+}
+
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
@@ -3983,7 +3997,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
- async sendInput(sessionId: string, input: string): Promise {
+ async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise {
+ if (options?.beforeDispatch && input !== '\r') return false;
const session = this.sessions.get(sessionId);
if (!session) {
console.error(
@@ -3996,7 +4011,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// inputRunner() so a test subclass can record what WOULD reach tmux.
const run = this.inputRunner();
if (!run) {
- return true;
+ return options?.beforeDispatch?.() ?? true;
}
console.log(
@@ -4050,7 +4065,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
);
} else if (hasCarriageReturn) {
- // Enter only
+ // Enter only: no await between the guard and dispatch.
+ if (options?.beforeDispatch && !options.beforeDispatch()) return false;
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
}
@@ -4279,7 +4295,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
- if (IS_TEST_MODE) return '';
+ const exec = this.captureRunner();
+ if (!exec) return '';
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) {
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
@@ -4317,9 +4334,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
+ const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`;
+ const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`;
+ // The visible frame and its caret come from ONE tmux invocation
+ // (the capture, `\;`, then the cursor query): tmux runs a command list back to back
+ // in its command queue, with no pane output parsed between the two, so the
+ // caret belongs to the frame. Two separate calls raced the CLI's repaint: a
+ // CLI that redraws its composer a few ms after input arrives (codex) could be
+ // captured before the redraw and its caret queried after it, and guarded
+ // stage then refused a frame the keys had already reached. Only output that
+ // splitFrameAndCursor() cannot split (a last line that is not the query's)
+ // falls back to the two separate calls, which is what this always did. A call
+ // that THREW (a hung tmux timing out) goes to the catch below like any other
+ // capture failure: retrying it would block the event loop for a second timeout
+ // on every `GET .../terminal` load.
+ const combined = fullHistory
+ ? null
+ : splitFrameAndCursor(
+ exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
+ );
const rawCapture = joinScrollbackAndFrame(
scrollbackOverride,
- execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
+ combined?.frame ?? exec(captureCommand, execOpts),
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
);
// Query the cursor BEFORE deciding anything else. On the full-history path
@@ -4327,10 +4363,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
- execSync(
- `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
- { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
- )
+ combined
+ ? combined.cursor
+ : exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
);
// Report the size the pane was really drawing at. The visible-frame path
// below addresses every row absolutely, so a consumer whose terminal is
@@ -4396,6 +4431,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
+ /**
+ * How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest).
+ * Split out like {@link inputRunner} so a test subclass can count and script the
+ * calls a capture makes.
+ */
+ protected captureRunner():
+ | ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string)
+ | null {
+ if (IS_TEST_MODE) return null;
+ return (cmd, opts) => execSync(cmd, opts);
+ }
+
/**
* Capture the active pane for a tmux session.
*
diff --git a/src/types/session.ts b/src/types/session.ts
index 7186b644e..2e72939fe 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -33,6 +33,7 @@
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
+import type { GitFetchStatus } from '../git-fetch.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -891,6 +892,20 @@ export interface SessionState {
keepWarmEnabled?: boolean;
/** What the keep-warm is doing right now; ephemeral, never restored. */
keepWarm?: KeepWarmStatus;
+ /**
+ * Git fetch on an interval for this session's repositories; absent = follow the app-wide
+ * `gitFetchEnabled` default. Restored on a restart, like `keepWarmEnabled`, because the choice is the
+ * user's and outlives the run.
+ */
+ gitFetchEnabled?: boolean;
+ /** Seconds between this session's runs; absent = follow the app-wide `gitFetchIntervalSeconds` default. */
+ gitFetchIntervalSeconds?: number;
+ /**
+ * What this session's last fetch did. Written to disk with the rest of the record, but the restore copies
+ * only the fields its own config lists and this is not one of them, so it is the panel's truth for the
+ * life of the session, not across a restart.
+ */
+ gitFetch?: GitFetchStatus;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md
index 1c884dfce..c6090e222 100644
--- a/src/web/CLAUDE.md
+++ b/src/web/CLAUDE.md
@@ -20,7 +20,7 @@
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
-**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
+**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a hand-built bracketed paste on the session input path (`_deliverBracketedPaste`: strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). When local echo owns the prompt, flush the unsent prefix first and delay the frame (#219). ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a ` `) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `` (a detached div with `innerHTML` set starts fetching every `` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `
`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
@@ -30,11 +30,11 @@
## Frontend modules and UI subsystems
-Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `\n`
);
+ // Which run modes keep their PTY rows under a phone keyboard (inline renderers,
+ // the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same
+ // shape and reasoning as the page-key list above.
+ const keyboardHoldClis = enabledClis()
+ .filter((entry) => entry.capabilities.keyboardHold === 'inline')
+ .map((entry) => entry.id);
+ html = html.replace(
+ '',
+ () => `\n`
+ );
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('', () => `\n`);
if (settings.gestureControlEnabled === true) {
@@ -3346,16 +3419,12 @@ export class WebServer extends EventEmitter {
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) {
- const settingsPath = dataPath('settings.json');
try {
- const content = await fs.readFile(settingsPath, 'utf-8');
- const settings = JSON.parse(content);
- settings.tunnelEnabled = false;
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
- } catch {
- /* ignore */
+ await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false }));
+ console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
+ } catch (err) {
+ console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err));
}
- console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
@@ -3540,6 +3609,9 @@ export class WebServer extends EventEmitter {
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
}
+ if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) {
+ session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds);
+ }
if (saved.autoResumeEnabled) {
// The stamp is re-armed by default, because a Codeman restart leaves the
// limit footer un-reprinted and dropping it there would strand the pause.
@@ -3762,6 +3834,11 @@ export class WebServer extends EventEmitter {
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
+ // The session's own fetch switch and interval, carried over rather than reset to the app default:
+ // the choice is the user's and outlives the run that made it. The scheduler reads them fresh
+ // on its next tick, so a restored session is back on its own schedule without a restart.
+ gitFetchEnabled: savedState?.gitFetchEnabled,
+ gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -4233,6 +4310,9 @@ export class WebServer extends EventEmitter {
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
+ // Stops the fetch sweep from starting another run while the server is closing; a git process already
+ // running is left to finish, which is what a fetch does to a repository nothing else is touching.
+ this.gitFetchScheduler.stop();
// A post within the debounce window before shutdown would otherwise not reach disk.
// Flushed again after `app.close()` below, for a post that lands in between.
agentInboxPersistence.flush();
@@ -4399,9 +4479,19 @@ export async function startWebServer(
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
- basePath: string = ''
+ basePath: string = '',
+ allowUnauthenticatedTunnel: boolean = false
): Promise {
- const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
+ const server = new WebServer(
+ port,
+ https,
+ testMode,
+ host,
+ titleHostname,
+ allowUnauthenticatedNetwork,
+ basePath,
+ allowUnauthenticatedTunnel
+ );
await server.start();
return server;
}
diff --git a/src/web/settings-writer.ts b/src/web/settings-writer.ts
new file mode 100644
index 000000000..d5bbaca49
--- /dev/null
+++ b/src/web/settings-writer.ts
@@ -0,0 +1,118 @@
+/**
+ * @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`).
+ *
+ * Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as
+ * `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is
+ * config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that
+ * does not parse, which this writer refuses.
+ *
+ * - **Serialized.** Mutations run one at a time on a single promise chain, each one reading,
+ * changing and writing before the next starts. The writers used to run unserialized with a
+ * plain truncate-then-write `fs.writeFile`, and quick-start did not even await its
+ * `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one
+ * write's bytes over the tail of a longer one) or drop the other writer's key.
+ * - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it,
+ * so a reader never sees a half-written file.
+ * - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file
+ * that cannot be read or does not parse is left alone and the write throws
+ * `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only
+ * their own keys, which erased every other setting.
+ */
+
+import { randomUUID } from 'node:crypto';
+import fs from 'node:fs/promises';
+import { dirname } from 'node:path';
+import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js';
+import { SETTINGS_PATH } from './route-helpers.js';
+
+export type SettingsFile = Record;
+
+/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */
+export class SettingsWriteRefusedError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SettingsWriteRefusedError';
+ }
+}
+
+async function readSettingsForWrite(path: string): Promise {
+ let raw: string;
+ try {
+ raw = await fs.readFile(path, 'utf-8');
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
+ throw new SettingsWriteRefusedError(
+ `Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
+ );
+ }
+ // An empty file holds no settings to lose.
+ if (raw.trim() === '') return {};
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = undefined;
+ }
+ // The parser's own message is not repeated: it can quote the file, which may hold an API key.
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ throw new SettingsWriteRefusedError(
+ `${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`
+ );
+ }
+ return parsed as SettingsFile;
+}
+
+async function writeSettingsFile(path: string, settings: SettingsFile): Promise {
+ // Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
+ const target = await fs.realpath(path).catch(() => path);
+ await fs.mkdir(dirname(target), { recursive: true });
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
+ try {
+ await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 });
+ await fs.rename(tmp, target);
+ } catch (err) {
+ await fs.rm(tmp, { force: true }).catch(() => {});
+ throw err;
+ }
+}
+
+let mutationChain: Promise = Promise.resolve();
+
+/**
+ * Run one change to `settings.json`. `fn` receives the current settings and returns the settings
+ * to write, or `undefined` to write nothing. Resolves with the settings before and after the
+ * change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects
+ * its own caller only; the chain keeps going.
+ */
+export function mutateSettingsFile(
+ fn: (current: SettingsFile) => SettingsFile | undefined
+): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> {
+ const run = mutationChain.then(async () => {
+ const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => {
+ console.warn(`[settings] write refused: ${getErrorMessage(err)}`);
+ throw err;
+ });
+ const after = fn({ ...before });
+ if (after) await writeSettingsFile(SETTINGS_PATH, after);
+ return { before, after };
+ });
+ mutationChain = run.catch(() => {});
+ return run;
+}
+
+/**
+ * Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right
+ * now: the writer's own read-for-write rule, run on its own. For a route that does something it
+ * cannot take back (case rename ends sessions) before its settings write. A check only, outside
+ * the chain (writes are atomic, so it never reads a half-written file); the file can still change
+ * before the write.
+ */
+export async function assertSettingsWritable(): Promise {
+ await readSettingsForWrite(SETTINGS_PATH);
+}
+
+/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */
+export function settingsWriteErrorResponse(err: unknown): ApiResponse {
+ if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+}
diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts
new file mode 100644
index 000000000..a0e571768
--- /dev/null
+++ b/test/agent-codex-identity.test.ts
@@ -0,0 +1,253 @@
+/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import {
+ __clearCodexAttributionMemo,
+ findExactCodexConversation,
+ resolveEffectiveCodexHome,
+} from '../src/agent-codex-identity.js';
+const roots: string[] = [];
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+const A = '11111111-1111-4111-8111-111111111111';
+const B = '22222222-2222-4222-8222-222222222222';
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'agent-attribution-'));
+ roots.push(root);
+ const home = join(root, 'home');
+ await mkdir(join(home, 'sessions'), { recursive: true });
+ const options = { codexHome: home, sessionId: 'managed', workingDir: root };
+ const put = async (id: string, payload: object = {}, text?: string) => {
+ const path = join(home, 'sessions', `rollout-${id}.jsonl`);
+ await writeFile(
+ path,
+ text ??
+ JSON.stringify({
+ type: 'session_meta',
+ payload: { id, cwd: root, originator: 'codeman_managed', ...payload },
+ }) + '\n'
+ );
+ return path;
+ };
+ return { root, home, options, put };
+}
+it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => {
+ const f = await fixture();
+ const path = await f.put(A);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
+ await f.put(A, { id: B });
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+});
+it('never chooses newest cwd files or an ambiguous managed originator', async () => {
+ const f = await fixture();
+ await f.put(A, { originator: 'codex_cli' });
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+ await f.put(A);
+ await f.put(B);
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+});
+it('allows an explicit resume identity but still requires native metadata and the workspace', async () => {
+ const f = await fixture();
+ const path = await f.put(A, { originator: 'original-launch' });
+ expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path });
+ await f.put(B);
+ expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
+ await f.put(A, { cwd: f.home });
+ expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
+});
+it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => {
+ const f = await fixture();
+ const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } });
+ await f.put(
+ A,
+ {},
+ kind === 'partial'
+ ? row
+ : kind === 'duplicate'
+ ? row + '\n' + row + '\n'
+ : row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n'
+ );
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+});
+it('enforces traversal/read caps and ignores symlinked directories', async () => {
+ const f = await fixture();
+ await f.put(A);
+ expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined();
+ expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined();
+ await mkdir(join(f.root, 'elsewhere'));
+ await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias'));
+ expect((await findExactCodexConversation(f.options))?.id).toBe(A);
+});
+it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => {
+ expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom');
+ expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process');
+ expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex');
+});
+
+it.each([
+ ['trailing-space', 'codex-home ', 'codex-home'],
+ ['whitespace-only', ' ', '.codex'],
+])(
+ 'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper',
+ async (_label, configured, wrongHome) => {
+ const { Session } = await import('../src/session.js');
+ const f = await fixture();
+ const expectedHome = join(f.root, configured);
+ const siblingHome = join(f.root, wrongHome);
+ const record =
+ JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n';
+ for (const home of [expectedHome, siblingHome]) {
+ await mkdir(join(home, 'sessions'), { recursive: true });
+ await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record);
+ }
+ const session = new Session({
+ id: 'managed',
+ workingDir: f.root,
+ mode: 'codex',
+ envOverrides: { CODEX_HOME: configured },
+ });
+ try {
+ const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root });
+ expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({
+ id: A,
+ path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`),
+ });
+ expect(session.getCodexHomeOverride()).toBe(configured);
+ } finally {
+ await session.stop(false);
+ }
+ }
+);
+
+it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => {
+ const f = await fixture();
+ const path = await f.put(A);
+ await Promise.all(
+ Array.from({ length: 320 }, (_, n) => {
+ const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`;
+ return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) });
+ })
+ );
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
+ expect(
+ await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 })
+ ).toBeUndefined();
+});
+
+it.each(['workspace', 'metadata'] as const)(
+ 'probes %s bounded past the cap before canonical filesystem reads',
+ async (where) => {
+ const { vi } = await import('vitest');
+ const probe = await import('../src/utils/bounded-path-probe.js');
+ const f = await fixture();
+ const alias = join(f.root, 'workspace-alias');
+ await symlink(f.root, alias);
+ await f.put(A, { cwd: where === 'metadata' ? alias : f.root });
+ const blocked = where === 'workspace' ? f.root : alias;
+ const spy = vi
+ .spyOn(probe, 'probePathKind')
+ .mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory'));
+ try {
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+ expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true });
+ } finally {
+ spy.mockRestore();
+ }
+ }
+);
+
+describe('remembered attribution on a large, never-pruned store', () => {
+ beforeEach(() => __clearCodexAttributionMemo());
+ const OWNED = '33333333-3333-4333-8333-333333333333';
+ const NEWER = '44444444-4444-4444-8444-444444444444';
+ /** codex's own layout: sessions/YYYY/MM/DD/rollout--.jsonl */
+ async function dated(f: Awaited>, day: string, id: string, payload: object = {}) {
+ const dir = join(f.home, 'sessions', ...day.split('/'));
+ await mkdir(dir, { recursive: true });
+ const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
+ await writeFile(
+ path,
+ JSON.stringify({
+ type: 'session_meta',
+ payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
+ }) + '\n'
+ );
+ return path;
+ }
+ /** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
+ async function unrelated(f: Awaited>, count: number) {
+ const instructions = 'x'.repeat(19 * 1024);
+ for (let start = 0; start < count; start += 200) {
+ await Promise.all(
+ Array.from({ length: Math.min(200, count - start) }, (_, k) => {
+ const n = start + k;
+ const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
+ return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
+ originator: 'codex_cli',
+ instructions,
+ });
+ })
+ );
+ }
+ }
+ const small = { maxEntries: 20, maxBytes: 1048576 };
+
+ it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
+ const f = await fixture();
+ await unrelated(f, 2100);
+ const path = await dated(f, '2026/10/24', OWNED);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ }, 60_000);
+
+ it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
+ const f = await fixture();
+ await unrelated(f, 300);
+ const path = await dated(f, '2026/10/24', OWNED);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ // 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
+ // remembered head plus the directories not older than it fit.
+ expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
+ __clearCodexAttributionMemo();
+ expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
+ });
+
+ it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
+ const f = await fixture();
+ await unrelated(f, 50);
+ const path = await dated(f, '2026/10/24', OWNED);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ await dated(f, '2026/10/25', NEWER);
+ expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+ });
+
+ it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
+ const f = await fixture();
+ const path = await dated(f, '2026/10/24', OWNED);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ // 30 rollouts in the remembered day itself: the incremental scan reads them all,
+ // so 20 entries cannot cover it, and a full walk (a superset) could not either.
+ await Promise.all(
+ Array.from({ length: 30 }, (_, n) =>
+ dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
+ )
+ );
+ expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
+ // The memo was dropped, so a call with room walks the store and attributes again.
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ });
+
+ it('drops the remembered file when it is replaced, and walks again', async () => {
+ const f = await fixture();
+ const path = await dated(f, '2026/10/24', OWNED);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
+ await rm(path);
+ await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
+ expect(await findExactCodexConversation(f.options)).toBeUndefined();
+ const moved = await dated(f, '2026/10/23', NEWER);
+ expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
+ });
+});
diff --git a/test/agent-coordination-setting.test.ts b/test/agent-coordination-setting.test.ts
new file mode 100644
index 000000000..57ac54b15
--- /dev/null
+++ b/test/agent-coordination-setting.test.ts
@@ -0,0 +1,90 @@
+/**
+ * @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
+ * experimental inspect/stage/submit routes: the strict settings schema admits it, App
+ * Settings loads and saves it under the same control id, and its row reads in Chinese
+ * (zh-CN) with no English left beyond the command and product names. The route gate itself
+ * is pinned in test/routes/agent-session-routes.test.ts.
+ *
+ * Port: N/A.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, describe, expect, it } from 'vitest';
+import { SettingsUpdateSchema } from '../src/web/schemas.js';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
+const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
+const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
+const ID = 'appSettingsAgentCoordination';
+
+type Api = { configure(o: Record): void; start(): void };
+
+/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
+const leftover = (text: string) =>
+ text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
+
+function rowStrings(doc: Document): { label: string; description: string } {
+ const row = doc.getElementById(ID)!.closest('.set-row')!;
+ return {
+ label: row.querySelector('.set-row-label')!.textContent!.trim(),
+ description: row.querySelector('.set-row-desc')!.textContent!.trim(),
+ };
+}
+
+const source = new JSDOM(INDEX);
+const english = rowStrings(source.window.document);
+const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
+vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
+const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
+api.start();
+api.configure({ language: 'zh-CN' });
+const chinese = rowStrings(dom.window.document);
+
+afterAll(() => {
+ source.window.close();
+ dom.window.close();
+});
+
+describe('agentCoordinationEnabled', () => {
+ it('is a boolean the strict settings schema accepts, and nothing else', () => {
+ expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
+ expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
+ expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
+ });
+
+ it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
+ expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
+ expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
+ expect(SETTINGS_UI).toContain(
+ `document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
+ );
+ expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
+ });
+
+ it('sits in the synced Cross-session features group', () => {
+ const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
+ expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
+ expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
+ });
+
+ it('finds the row (the translation check is not vacuous)', () => {
+ expect(english.label).toBe('Experimental agent coordination');
+ expect(english.description).toMatch(/^Lets agents read another session's output/);
+ });
+
+ it('translates the label and the description, with no English left', () => {
+ for (const key of ['label', 'description'] as const) {
+ expect(chinese[key]).not.toBe(english[key]);
+ expect(leftover(chinese[key])).toEqual([]);
+ }
+ });
+
+ it('adds each key to the dictionary once', () => {
+ for (const text of [english.label, english.description]) {
+ expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
+ }
+ });
+});
diff --git a/test/agent-inbox-server-wiring.test.ts b/test/agent-inbox-server-wiring.test.ts
index 6cb416cd1..a0987815c 100644
--- a/test/agent-inbox-server-wiring.test.ts
+++ b/test/agent-inbox-server-wiring.test.ts
@@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
+ gitFetchScheduler: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts
new file mode 100644
index 000000000..cadbf53f8
--- /dev/null
+++ b/test/agent-inspection.test.ts
@@ -0,0 +1,202 @@
+/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */
+import { afterEach, beforeEach, expect, it } from 'vitest';
+import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js';
+let dir: string;
+let file: string;
+let reader: InspectionReader;
+const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' };
+beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'inspect-'));
+ file = join(dir, 'rollout.jsonl');
+ reader = new InspectionReader();
+});
+afterEach(async () => {
+ await rm(dir, { recursive: true, force: true });
+});
+it('returns complete UTF8 records within the byte budget and resumes exactly', async () => {
+ const row = JSON.stringify({ text: 'é😀' }) + '\n';
+ await writeFile(file, row + row);
+ const first = await reader.read(file, scope, Buffer.byteLength(row));
+ expect(first.evidence).toBe(row);
+ expect(first.truncated).toBe(true);
+ await appendFile(file, row);
+ const next = await reader.read(file, scope, 1024, first.cursor);
+ expect(next.evidence).toBe(row);
+ expect(next.truncated).toBe(false);
+});
+it('does not consume partial final records', async () => {
+ await writeFile(file, '{"text":"hello');
+ const first = await reader.read(file, scope, 100);
+ expect(first.evidence).toBe('');
+ await appendFile(file, '"}\n');
+ const next = await reader.read(file, scope, 100, first.cursor);
+ expect(next.evidence).toBe('{"text":"hello"}\n');
+});
+it('reports an oversized incremental record without splitting it or advancing past it', async () => {
+ await writeFile(file, '{}\n');
+ const initial = await reader.read(file, scope, 100);
+ await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n');
+ const result = await reader.read(file, scope, 100, initial.cursor);
+ expect(result.evidence).toBe('');
+ expect(result.truncated).toBe(true);
+ expect(result.blockedReason).toBe('record-exceeds-scan-limit');
+ expect(result.scannedBytes).toBeLessThanOrEqual(262144);
+});
+it('rejects malformed, cross-owner and changed-session cursors', async () => {
+ await writeFile(file, '{}\n');
+ const first = await reader.read(file, scope, 100);
+ for (const changed of [
+ { ...scope, owner: 'bob' },
+ { ...scope, sessionId: 'other' },
+ { ...scope, generation: 'new' },
+ { ...scope, conversationId: 'new' },
+ ]) {
+ await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i);
+ }
+ await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i);
+});
+it('rejects truncation, replacement and rewritten consumed evidence', async () => {
+ await writeFile(file, '{}\n{}\n');
+ const first = await reader.read(file, scope, 100);
+ await writeFile(file, '{}\n');
+ await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
+ await writeFile(file, '{}\n{}\n');
+ const second = await reader.read(file, scope, 100);
+ await writeFile(file, '[]\n[]\n');
+ await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i);
+ const third = await reader.read(file, scope, 100);
+ await rename(file, join(dir, 'old'));
+ await writeFile(file, '[]\n[]\n');
+ await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i);
+});
+it('expires cursors and bounds retained state', async () => {
+ await writeFile(file, '{}\n');
+ let now = 0;
+ reader = new InspectionReader(() => now, 2, 1000);
+ const first = await reader.read(file, scope, 100);
+ await reader.read(file, scope, 100);
+ await reader.read(file, scope, 100);
+ await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
+ const last = await reader.read(file, scope, 100);
+ now = 1001;
+ await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i);
+});
+it('bounds strict attribution itself and refuses an incomplete inventory', async () => {
+ const { mkdir } = await import('node:fs/promises');
+ const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
+ const home = join(dir, 'codex');
+ const sessions = join(home, 'sessions');
+ await mkdir(sessions, { recursive: true });
+ const id = '11111111-1111-4111-8111-111111111111';
+ await writeFile(
+ join(sessions, `rollout-${id}.jsonl`),
+ JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n'
+ );
+ await writeFile(join(sessions, 'other.txt'), 'other');
+ const options = { codexHome: home, sessionId: 's', workingDir: dir };
+ expect((await findExactCodexConversation(options))?.id).toBe(id);
+ expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined();
+});
+
+it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => {
+ const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n';
+ const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n';
+ await writeFile(file, old + latest);
+ const result = await reader.read(file, scope, Buffer.byteLength(latest));
+ expect(result.evidence).toBe(latest);
+ expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old));
+ expect(result.truncated).toBe(true);
+ await appendFile(file, '{"assistant":"next"}\n');
+ expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n');
+});
+it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => {
+ const { mkdir } = await import('node:fs/promises');
+ const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
+ const home = join(dir, 'codex');
+ await mkdir(join(home, 'sessions'), { recursive: true });
+ const id = '11111111-1111-4111-8111-111111111111';
+ await writeFile(
+ join(home, 'sessions', `rollout-${id}.jsonl`),
+ JSON.stringify({
+ type: 'session_meta',
+ payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) },
+ }) + '\n'
+ );
+ expect(
+ (
+ await findExactCodexConversation(
+ { codexHome: home, sessionId: 's', workingDir: dir },
+ { maxEntries: 8192, maxBytes: 1048576 }
+ )
+ )?.id
+ ).toBe(id);
+});
+it('rejects same-size rewrites outside the bounded cursor anchors', async () => {
+ const { utimes } = await import('node:fs/promises');
+ const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n';
+ await writeFile(file, row);
+ await utimes(file, 1, 1);
+ const first = await reader.read(file, scope, 30000);
+ await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001));
+ await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i);
+});
+it('keeps cursor capacity bounded across concurrent reads', async () => {
+ await writeFile(file, '{}\n');
+ reader = new InspectionReader(Date.now, 2);
+ const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100)));
+ const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor)));
+ expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2);
+});
+it('caps cursors per owner and session, so one polling scope cannot evict another', async () => {
+ await writeFile(file, '{}\n');
+ const other = { ...scope, owner: 'bob', sessionId: 't' };
+ const bobs = await reader.read(file, other, 100);
+ // Alice polls every second for well past the old global FIFO of 1,024.
+ let last = await reader.read(file, scope, 100);
+ for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor);
+ // Bob's cursor survives, and so does a retry of Alice's last call.
+ await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' });
+ await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' });
+ // Alice keeps only her newest cursors: the oldest of one more than the cap is retired.
+ const issued: string[] = [];
+ for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor);
+ await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i);
+ await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined();
+ // The cap counts owner and session: a new pane generation of Alice's session shares
+ // it, Bob's session does not.
+ const nextPane = { ...scope, generation: 'pane-2' };
+ for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100);
+ await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i);
+ await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined();
+});
+it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => {
+ const row = Buffer.from('{"text":"😀"}\n');
+ const split = row.indexOf(Buffer.from('😀')) + 2;
+ await writeFile(file, row.subarray(0, split));
+ const first = await reader.read(file, scope, 100);
+ expect(first.evidence).toBe('');
+ await appendFile(file, row.subarray(split));
+ expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8'));
+});
+
+it('treats a full incremental page as pageable rather than a blocked oversized record', async () => {
+ await writeFile(file, '{}\n');
+ const initial = await reader.read(file, scope, 100);
+ await appendFile(file, '{}\n{}\n');
+ const page = await reader.read(file, scope, 3, initial.cursor);
+ expect(page.evidence).toBe('{}\n');
+ expect(page.truncated).toBe(true);
+ expect(page.blockedReason).toBeUndefined();
+ const next = await reader.read(file, scope, 3, page.cursor);
+ expect(next.evidence).toBe('{}\n');
+});
+
+it('still labels the first selected oversized tail record after omitted older bytes', async () => {
+ await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n');
+ const page = await reader.read(file, scope, 10);
+ expect(page.evidence).toBe('');
+ expect(page.blockedReason).toBe('record-exceeds-output-budget');
+});
diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts
new file mode 100644
index 000000000..38d2d852a
--- /dev/null
+++ b/test/agent-staging-session.test.ts
@@ -0,0 +1,544 @@
+/** @fileoverview Guarded input observes all production write and lifecycle primitives. */
+import { expect, it, vi } from 'vitest';
+import { formatPaneSnapshot } from '../src/tmux-manager.js';
+import { Session } from '../src/session.js';
+import { promptSha256 } from '../src/agent-staging.js';
+it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
+ let screen = '› \n GPT-6-Luna default · ~/case\n';
+ const mux = {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot(screen.trimEnd().split('\n'), {
+ cols: 100,
+ rows: 20,
+ cursorX: screen.startsWith('› hello') ? 7 : 2,
+ cursorY: 0,
+ });
+ },
+ sendInput: async () => true,
+ };
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux,
+ muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
+ } as unknown as ConstructorParameters[0]);
+ const before = session.guardedInputSnapshot();
+ expect(before?.composerSha).toBe(promptSha256(''));
+ await session.writeViaMux('hello');
+ screen = '› hello\n GPT-6-Luna default · ~/case\n';
+ expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1);
+ session.trackUserInput('x');
+ expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2);
+ await session.performAuxiliaryInput(async () => {});
+ expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3);
+ const generation = session.inspectionGeneration;
+ await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle(
+ async () => {
+ expect(session.guardedInputSnapshot()).toBeNull();
+ }
+ );
+ expect(session.inspectionGeneration).not.toBe(generation);
+ session.markPromptSubmitted();
+ expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4);
+ await session.stop(false);
+ expect(session.guardedInputSnapshot()).toBeNull();
+});
+
+it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => {
+ let dispatch: (() => void) | undefined;
+ const writes: string[] = [];
+ const mux = {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 7,
+ cursorY: 0,
+ });
+ },
+ sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
+ await new Promise((resolve) => {
+ dispatch = resolve;
+ });
+ if (options?.beforeDispatch && !options.beforeDispatch()) return false;
+ writes.push(text);
+ return true;
+ },
+ };
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux,
+ muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
+ const expected = session.guardedInputSnapshot()!;
+ const sending = session.submitGuardedInput(expected);
+ // Held behind the lease, not refused: it still invalidates the pending Enter, and
+ // it goes out once the lease is released.
+ const other = session.writeViaMux('other');
+ dispatch!();
+ expect(await sending).toBe(false);
+ expect(writes).toEqual([]);
+ await settle();
+ dispatch!();
+ expect(await other).toBe(true);
+ expect(writes).toEqual(['other']);
+ const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
+ dispatch!();
+ expect(await next).toBe(true);
+ expect(writes).toEqual(['other', '\r']);
+ const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!);
+ await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle(
+ async () => {}
+ );
+ dispatch!();
+ expect(await restarting).toBe(false);
+ expect(writes).toEqual(['other', '\r']);
+ const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
+ const key = session.performAuxiliaryInput(async () => {
+ writes.push('aux');
+ });
+ await settle();
+ // The named key waits for the lease instead of failing, and never runs inside it.
+ expect(writes).toEqual(['other', '\r']);
+ dispatch!();
+ expect(await auxiliary).toBe(false);
+ await key;
+ expect(writes).toEqual(['other', '\r', 'aux']);
+ } finally {
+ await session.stop(false);
+ }
+});
+
+it('cancels an older automatic submit verifier before staging an identical prompt', async () => {
+ vi.useFakeTimers();
+ let composer = '';
+ const writes: string[] = [];
+ const mux = {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 2 + composer.length,
+ cursorY: 0,
+ });
+ },
+ capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`,
+ sendInput: async (_id: string, text: string) => {
+ writes.push(text);
+ composer += text;
+ return true;
+ },
+ };
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux,
+ muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ session.verifySubmitted('hello');
+ expect(await session.stageGuardedInput('hello')).toBe(true);
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(writes).toEqual(['hello']);
+ } finally {
+ await session.stop(false);
+ vi.useRealTimers();
+ }
+});
+
+it('routes automatic verifier Enter through the shared guarded input boundary', async () => {
+ vi.useFakeTimers();
+ let release!: () => void;
+ const writes: string[] = [];
+ const mux = {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 7,
+ cursorY: 0,
+ });
+ },
+ capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case',
+ sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
+ if (options?.beforeDispatch) {
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ if (!options.beforeDispatch()) return false;
+ }
+ writes.push(text);
+ return true;
+ },
+ };
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux,
+ muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ session.verifySubmitted('hello');
+ const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
+ await vi.advanceTimersByTimeAsync(2000);
+ release();
+ expect(await submission).toBe(false);
+ await vi.advanceTimersByTimeAsync(60000);
+ expect(writes).toEqual([]);
+ } finally {
+ await session.stop(false);
+ vi.useRealTimers();
+ }
+});
+
+it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => {
+ let release!: () => void;
+ const writes: string[] = [];
+ const mux = {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 7,
+ cursorY: 0,
+ });
+ },
+ sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ if (options?.beforeDispatch && !options.beforeDispatch()) return false;
+ writes.push(text);
+ return true;
+ },
+ };
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux,
+ muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
+ try {
+ const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
+ // This also prevents a real spawn if the lease guard regresses.
+ access.ptyProcess = {
+ write: () => {
+ throw new Error('must not write');
+ },
+ };
+ await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission');
+ await expect(session.sendInput('other')).rejects.toThrow('Guarded submission');
+ access.ptyProcess = null;
+ release();
+ expect(await submission).toBe(false);
+ expect(writes).toEqual([]);
+ } finally {
+ access.ptyProcess = null;
+ await session.stop(false);
+ }
+});
+
+it('counts an ordinary transport pending window before any awaited acknowledgement', async () => {
+ let release!: () => void;
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: {
+ isAvailable: () => true,
+ sendInput: async () => {
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ return true;
+ },
+ },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ const writing = session.writeViaMux('hello');
+ expect(session.guardedInputPendingWrites).toBe(1);
+ release();
+ await writing;
+ expect(session.guardedInputPendingWrites).toBe(0);
+ } finally {
+ release?.();
+ await session.stop(false);
+ }
+});
+
+it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => {
+ let working = false;
+ const capture = vi.fn(() =>
+ formatPaneSnapshot(
+ [working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'],
+ { cols: 100, rows: 20, cursorX: 7, cursorY: 1 }
+ )
+ );
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return capture();
+ },
+ capturePaneText: () => {
+ throw Error('must use same frame');
+ },
+ },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked');
+ expect(session.guardedInputSnapshot()).not.toBeNull();
+ expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n');
+ working = true;
+ expect(session.guardedInputSnapshot()).toBeNull();
+ blocked.mockReturnValue(true);
+ working = false;
+ expect(session.guardedInputSnapshot()).toBeNull();
+ } finally {
+ await session.stop(false);
+ }
+});
+
+it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => {
+ const writes: string[] = [];
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 7,
+ cursorY: 0,
+ });
+ },
+ },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ const access = session as unknown as {
+ ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null;
+ };
+ access.ptyProcess = {
+ write: (text) => {
+ writes.push(text);
+ },
+ resize: () => {},
+ };
+ try {
+ const before = session.guardedInputSnapshot()!;
+ for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) {
+ expect(session.write(data, { fromUser: true })).toBe(true);
+ expect(writes.at(-1)).toBe(data);
+ expect(session.guardedInputSnapshot()?.revision).toBe(before.revision);
+ }
+ session.write('\x1b[Oedit');
+ expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1);
+ const generation = session.inspectionGeneration;
+ session.owner = 'other';
+ session.owner = undefined;
+ expect(session.inspectionGeneration).not.toBe(generation);
+ const priorResize = session.guardedInputGeneration;
+ session.resize(80, 24);
+ expect(session.guardedInputGeneration).not.toBe(priorResize);
+ } finally {
+ access.ptyProcess = null;
+ await session.stop(false);
+ }
+});
+
+it('refuses a message that would wrap using the actual captured indent and width before any write', async () => {
+ const writes: string[] = [];
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane: string,
+ opts: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 30, rows: 20 };
+ return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], {
+ cols: 30,
+ rows: 20,
+ cursorX: 6,
+ cursorY: 0,
+ });
+ },
+ sendInput: async (_id: string, text: string) => {
+ writes.push(text);
+ return true;
+ },
+ },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
+ const { getCli } = await import('../src/config/cli-registry/registry.js');
+ try {
+ const store = new GuardedStageStore();
+ const target = {
+ id: session.id,
+ chrome: () => guardedComposerChrome(getCli('codex')),
+ snapshot: () => session.guardedInputSnapshot(),
+ write: (text: string) => session.stageGuardedInput(text),
+ submit: (snap: Parameters[0]) => session.submitGuardedInput(snap),
+ };
+ await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
+ expect(writes).toEqual([]);
+ } finally {
+ await session.stop(false);
+ }
+});
+
+it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => {
+ const session = new Session({ workingDir: '/tmp', mode: 'codex' });
+ const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
+ access.ptyProcess = { resize: () => {} };
+ try {
+ const inspection = session.inspectionGeneration;
+ const staging = session.guardedInputGeneration;
+ session.resize(80, 24);
+ expect(session.inspectionGeneration).toBe(inspection);
+ expect(session.guardedInputGeneration).not.toBe(staging);
+ } finally {
+ access.ptyProcess = null;
+ await session.stop(false);
+ }
+});
+
+it('records a verifier re-press as the latest submit time', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(10000);
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ try {
+ await session.writeViaMux('hello\r');
+ const initial = session.lastSubmitAt;
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(session.lastSubmitAt).toBe(initial + 2000);
+ } finally {
+ await session.stop(false);
+ vi.useRealTimers();
+ }
+});
+
+it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
+ // Reproduces the live 0.162.0 race: right after the keys land, the screen still
+ // shows the empty composer while the caret is already past the new text. That
+ // frame proves nothing; the repaint a few ms later does.
+ let composer = '';
+ let torn = 0;
+ const writes: string[] = [];
+ const session = new Session({
+ workingDir: '/tmp',
+ mode: 'codex',
+ mux: {
+ isAvailable: () => true,
+ capturePaneBuffer: (
+ _name: string,
+ _pane?: string,
+ opts?: { capturedGeometry?: { cols: number; rows: number } }
+ ) => {
+ if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
+ const shown = torn > 0 ? '' : composer;
+ if (torn > 0) torn--;
+ return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], {
+ cols: 100,
+ rows: 20,
+ cursorX: 2 + composer.length,
+ cursorY: 0,
+ });
+ },
+ sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
+ if (options?.beforeDispatch && !options.beforeDispatch()) return false;
+ writes.push(text);
+ if (text !== '\r') {
+ composer += text;
+ torn = 2;
+ }
+ return true;
+ },
+ },
+ muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
+ } as unknown as ConstructorParameters[0]);
+ const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
+ const { getCli } = await import('../src/config/cli-registry/registry.js');
+ const sleeps: number[] = [];
+ const store = new GuardedStageStore(Date.now, 1024, 120000, {
+ sleep: async (ms) => {
+ sleeps.push(ms);
+ },
+ });
+ const target = {
+ id: session.id,
+ chrome: () => guardedComposerChrome(getCli('codex')),
+ snapshot: () => session.guardedInputSnapshot(),
+ write: (text: string) => session.stageGuardedInput(text),
+ submit: (snap: Parameters[0]) => session.submitGuardedInput(snap),
+ };
+ try {
+ const receipt = await store.stage(target, 'alice', 'hello');
+ expect(receipt.sha256).toBe(promptSha256('hello'));
+ expect(sleeps).toHaveLength(2);
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
+ submitted: true,
+ });
+ expect(writes).toEqual(['hello', '\r']);
+ } finally {
+ await session.stop(false);
+ }
+});
diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts
new file mode 100644
index 000000000..e6055b752
--- /dev/null
+++ b/test/agent-staging.test.ts
@@ -0,0 +1,380 @@
+/** @fileoverview Guarded staging one-shot and content-verification safety. */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { createHash } from 'node:crypto';
+import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
+import {
+ GuardedStageStore,
+ STAGE_SETTLE_INTERVAL_MS,
+ STAGE_SETTLE_TIMEOUT_MS,
+ SUBMIT_QUIET_MS,
+ composerTriggerRefusal,
+ guardedComposerChrome,
+ readGuardedComposer,
+ readGuardedSnapshot,
+ type GuardedInputTarget,
+} from '../src/agent-staging.js';
+import { getCli, listClis } from '../src/config/cli-registry/registry.js';
+/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
+const CODEX = guardedComposerChrome(getCli('codex'))!;
+const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
+const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
+const sha = (s: string) => createHash('sha256').update(s).digest('hex');
+let store: GuardedStageStore;
+let time: number;
+let generation: string;
+let revision: number;
+let composer: string | null;
+let writes: string[];
+let fail: boolean;
+let target: GuardedInputTarget;
+/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
+let sleeps: number[];
+const fakeSleep = async (ms: number) => {
+ sleeps.push(ms);
+ time += ms;
+};
+beforeEach(() => {
+ sleeps = [];
+ time = 0;
+ generation = 'pane-1';
+ revision = 0;
+ composer = '';
+ writes = [];
+ fail = false;
+ store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
+ target = {
+ id: 's',
+ chrome: () => CODEX,
+ snapshot: () =>
+ composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
+ submit: async () => target.write('\r'),
+ write: async (text) => {
+ writes.push(text);
+ revision++;
+ if (fail) return false;
+ composer = text === '\r' ? '' : composer + text;
+ return true;
+ },
+ };
+});
+it('stages without Enter, stores only a hash, and submits exactly once', async () => {
+ const receipt = await store.stage(target, 'alice', 'hello');
+ expect(writes).toEqual(['hello']);
+ expect(receipt.sha256).toBe(sha('hello'));
+ expect(JSON.stringify(receipt)).not.toContain('hello');
+ await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
+ expect(writes).toEqual(['hello', '\r']);
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
+});
+it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => {
+ for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800'])
+ await expect(store.stage(target, 'alice', text)).rejects.toThrow();
+ composer = 'existing';
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
+ composer = null;
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
+ expect(writes).toEqual([]);
+});
+it('refuses altered composer including whitespace-only differences', async () => {
+ const receipt = await store.stage(target, 'alice', 'hello world');
+ composer = 'helloworld';
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
+ expect(writes).toEqual(['hello world']);
+});
+it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => {
+ for (const change of [
+ () => {
+ generation = 'pane-2';
+ },
+ () => {
+ revision++;
+ },
+ () => {
+ time += 1001;
+ },
+ ]) {
+ composer = '';
+ const receipt = await store.stage(target, 'alice', 'hello');
+ change();
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
+ }
+ composer = '';
+ const receipt = await store.stage(target, 'alice', 'hello');
+ await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow();
+ await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
+ await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow();
+ expect(writes.filter((s) => s === '\r')).toHaveLength(0);
+});
+it('consumes before write so concurrent and failed deliveries never replay Enter', async () => {
+ const receipt = await store.stage(target, 'alice', 'hello');
+ fail = true;
+ const results = await Promise.allSettled([
+ store.submit(target, 'alice', receipt.stageId, receipt.sha256),
+ store.submit(target, 'alice', receipt.stageId, receipt.sha256),
+ ]);
+ expect(results.every((r) => r.status === 'rejected')).toBe(true);
+ expect(writes).toEqual(['hello', '\r']);
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
+});
+it('does not issue a receipt on failed staging or intervening input during its write', async () => {
+ fail = true;
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
+ fail = false;
+ composer = '';
+ const altered = {
+ ...target,
+ write: async (s: string) => {
+ await target.write(s);
+ revision++;
+ return true;
+ },
+ };
+ await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow();
+});
+
+it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
+ expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
+ expect(
+ readGuardedCodexComposer(
+ '› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts'
+ )
+ ).toBe('');
+ expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull();
+ expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull();
+ expect(readGuardedCodexComposer('› hello\n')).toBeNull();
+});
+it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
+ const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
+ const frame = (text: string, x: number, y = 0) =>
+ formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
+ expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello');
+ expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull();
+ expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull();
+ expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull();
+ expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull();
+ expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好');
+ expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
+});
+it('reserves receipt capacity before concurrent stages type', async () => {
+ store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
+ let release: (() => void) | undefined;
+ const firstTarget = {
+ ...target,
+ write: async (text: string) => {
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ return target.write(text);
+ },
+ };
+ const first = store.stage(firstTarget, 'alice', 'hello');
+ const otherTarget = { ...target, id: 'other' };
+ await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i);
+ expect(writes).toEqual([]);
+ release!();
+ await first;
+});
+
+it.each([...CODEX_REASONING_EFFORTS, 'default'])(
+ 'accepts the shared Codex effort word %s in the footer',
+ async (word) => {
+ expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
+ }
+);
+
+it('refuses messages that reach the available pane columns before typing', async () => {
+ target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 });
+ for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow();
+ expect(writes).toEqual([]);
+ await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
+});
+
+it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
+ const codex = getCli('codex')!.capabilities.workDetect!;
+ expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
+ expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
+ // Another glyph drives the same reader: nothing in it is codex-specific.
+ const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
+ expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
+ expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
+ const declaring = listClis()
+ .filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
+ .map((entry) => entry.id);
+ expect(declaring).toEqual(['codex']);
+ expect(guardedComposerChrome(getCli('claude'))).toBeNull();
+ // A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
+ const unsafe = structuredClone(getCli('codex')!);
+ unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
+ expect(guardedComposerChrome(unsafe)).toBeNull();
+});
+
+describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
+ /** Serve these post-write frames in order, then the target's own snapshot. */
+ function afterWrite(frames: Array<(() => ReturnType) | null>) {
+ let written = false;
+ const own = target.snapshot;
+ const write = target.write;
+ target.write = async (text) => {
+ const ok = await write(text);
+ written = true;
+ return ok;
+ };
+ target.snapshot = () => {
+ if (!written || frames.length === 0) return own();
+ const next = frames.shift()!;
+ return next ? next() : null;
+ };
+ }
+ const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
+
+ it('names its bounds', () => {
+ expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
+ expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
+ });
+ it('issues a receipt once a null first frame is followed by a valid one', async () => {
+ afterWrite([null]);
+ const receipt = await store.stage(target, 'alice', 'hello');
+ expect(receipt.sha256).toBe(sha('hello'));
+ expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
+ });
+ it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
+ afterWrite([stale, stale]);
+ await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
+ expect(sleeps).toHaveLength(2);
+ });
+ it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
+ target.snapshot = () => (writes.length ? null : stale());
+ const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
+ expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
+ expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
+ expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
+ });
+ it('issues no receipt for a frame whose composer text differs from the message', async () => {
+ const write = target.write;
+ target.write = async (text) => {
+ await write(text);
+ composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
+ return true;
+ };
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
+ // Nothing to submit: no receipt was stored for the frame that did not match.
+ composer = 'hello';
+ await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
+ /Unknown/
+ );
+ });
+ it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
+ afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
+ /Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
+ );
+ expect(sleeps).toEqual([]);
+ });
+ it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
+ fail = true;
+ await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
+ /^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
+ );
+ });
+});
+
+describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
+ const footer = ' gpt-5.6-terra default · ~/case';
+ /** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
+ const frame = (popup: string[], draft: string) =>
+ ['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
+
+ it('reads the idle frame with transcript above it', () => {
+ expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
+ });
+ it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
+ const popup = [
+ '› /status show current session configuration and token usage',
+ ' /statusline configure which items appear in the status line',
+ ];
+ expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
+ // A wrapped description row at the bottom of the list does not hide it.
+ expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
+ });
+ it('refuses the skill, mention and file-search popups', () => {
+ const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
+ const mention = [
+ ' Mentions',
+ ' All Results Filesystem Only Plugins',
+ ' sa',
+ '› Sample Plugin Plugin with skills and an MCP server Plugin',
+ ' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
+ ];
+ expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
+ expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
+ expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
+ expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
+ });
+ it('looks only at the block directly above the composer, not the transcript further up', () => {
+ const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
+ expect(readGuardedCodexComposer(screen)).toBe('hello');
+ });
+ it('takes the popup pattern and the trigger characters from the registry', () => {
+ const codex = getCli('codex')!.capabilities.workDetect!;
+ expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
+ expect(CODEX.commandPrefixes).toBe('/!');
+ expect(CODEX.completionSigils).toBe('@$');
+ // A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
+ const unsafe = structuredClone(getCli('codex')!);
+ unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
+ expect(guardedComposerChrome(unsafe)).toBeNull();
+ });
+ it.each([
+ ['/stat', '/'],
+ ['/status now', '/'],
+ [' /review', '/'],
+ ['!ls -la', '!'],
+ ['review @src/app', '@'],
+ ['@notes', '@'],
+ ['run $skill', '$'],
+ ['costs $5', '$'],
+ ])('refuses %j before typing (trigger %s)', async (message, trigger) => {
+ expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
+ await expect(store.stage(target, 'alice', message)).rejects.toThrow(
+ trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
+ );
+ expect(writes).toEqual([]);
+ });
+ it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
+ 'stages %j: no trigger at the start, and the caret word opens nothing',
+ async (message) => {
+ expect(composerTriggerRefusal(message, CODEX)).toBeNull();
+ await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
+ }
+ );
+});
+
+describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
+ it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
+ expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
+ const receipt = await store.stage(target, 'alice', 'hello');
+ time += 40;
+ await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
+ expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
+ expect(writes).toEqual(['hello', '\r']);
+ });
+ it('does not wait once the window has passed', async () => {
+ const receipt = await store.stage(target, 'alice', 'hello');
+ time += SUBMIT_QUIET_MS;
+ await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
+ expect(sleeps).toEqual([]);
+ });
+ it('still refuses when a keystroke lands during the wait', async () => {
+ store = new GuardedStageStore(() => time, 2, 1000, {
+ sleep: async (ms) => {
+ await fakeSleep(ms);
+ revision++; // the user typed while submit waited
+ },
+ });
+ const receipt = await store.stage(target, 'alice', 'hello');
+ await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
+ /changed; nothing submitted/
+ );
+ expect(writes).toEqual(['hello']);
+ });
+});
diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts
index 4045d8129..cdd39caf4 100644
--- a/test/app-settings-structure.test.ts
+++ b/test/app-settings-structure.test.ts
@@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
@@ -195,6 +196,47 @@ describe('App Settings modal structure', () => {
}
});
+ /**
+ * Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
+ * the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
+ * Off by default, and the interval clamped on save to the same bounds the schema accepts.
+ */
+ it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
+ const modal = settingsModal();
+ const bar = modal.match(/
Bottom bar<\/h4>([\s\S]*?)(?=
)/)?.[1];
+ expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
+ for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
+ const at = modal.indexOf(`id="${id}"`);
+ expect(at, `no element ${id}`).toBeGreaterThan(-1);
+ const row = modal.slice(modal.lastIndexOf('
', at));
+ expect(row).toContain('synced');
+ }
+ // The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
+ expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
+
+ // Display keys are the half of the per-device split that keeps a value off the server.
+ const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
+ const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
+ expect(displayBlock).not.toContain('gitFetchEnabled');
+ expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
+ // The strip destructure is the other half: an aliased key here never reaches the settings PUT.
+ expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
+
+ // Declared in the .strict() schema: an undeclared key 400s the whole settings PUT.
+ expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
+
+ // Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
+ expect(settingsUi).toContain(
+ "document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
+ );
+ expect(settingsUi).toContain(
+ "document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
+ );
+ expect(settingsUi).toMatch(
+ /gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
+ );
+ });
+
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
diff --git a/test/cli-agent-inspection.test.ts b/test/cli-agent-inspection.test.ts
new file mode 100644
index 000000000..d1858a3a5
--- /dev/null
+++ b/test/cli-agent-inspection.test.ts
@@ -0,0 +1,127 @@
+/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */
+import { expect, it } from 'vitest';
+import { mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ agentInspect,
+ agentStage,
+ agentSubmit,
+ registerAgentCommands,
+ type AgentDeps,
+ type RequestOptions,
+} from '../src/cli-agent.js';
+import { Command } from 'commander';
+const SELF = '11111111-1111-4111-8111-111111111111';
+const OTHER = '22222222-2222-4222-8222-222222222222';
+function deps() {
+ const calls: RequestOptions[] = [],
+ output: string[] = [];
+ const deps: AgentDeps = {
+ ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
+ json: true,
+ now: () => 1,
+ io: { out: (line) => output.push(line), err: () => {} },
+ request: async (_ctx, options) => {
+ calls.push(options);
+ return {
+ status: 200,
+ text: '',
+ json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } },
+ };
+ },
+ };
+ return { deps, calls, output };
+}
+it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => {
+ const d = deps();
+ expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0);
+ expect(d.calls[0]).toMatchObject({
+ method: 'GET',
+ path: `/api/v1/sessions/${OTHER}/inspect`,
+ query: { maxBytes: 42, since: 'cursor' },
+ });
+ expect(JSON.parse(d.output[0]).evidence).toBe('hello');
+ expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4);
+ expect(d.calls).toHaveLength(1);
+});
+it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
+ try {
+ const path = join(dir, 'message');
+ await writeFile(path, 'hello');
+ const d = deps();
+ expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0);
+ expect(d.calls[0]).toMatchObject({
+ method: 'POST',
+ path: `/api/v1/sessions/${OTHER}/stage`,
+ body: { message: 'hello' },
+ });
+ expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0);
+ expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) });
+ await writeFile(path, 'hello\n');
+ expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
+ await writeFile(path, Buffer.from([0xff]));
+ expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
+ await writeFile(path, 'x'.repeat(8193));
+ expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
+ expect(d.calls).toHaveLength(2);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+it('refuses self-target and malformed submission before transport', async () => {
+ const d = deps();
+ expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4);
+ expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4);
+ expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4);
+ expect(d.calls).toHaveLength(0);
+});
+it('registers required file and confirmation options and bounded inspect flags', () => {
+ const program = new Command();
+ registerAgentCommands(program);
+ const commands = program.commands.find((command) => command.name() === 'agent')!.commands;
+ for (const name of ['inspect', 'stage', 'submit'])
+ expect(commands.some((command) => command.name() === name)).toBe(true);
+ expect(
+ commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file')
+ ?.mandatory
+ ).toBe(true);
+ expect(
+ commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha')
+ ?.mandatory
+ ).toBe(true);
+});
+it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
+ try {
+ const path = join(dir, 'message');
+ await writeFile(path, 'hello');
+ const message =
+ 'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
+ for (const json of [false, true]) {
+ const lines: string[] = [];
+ const refusing: AgentDeps = {
+ ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
+ json,
+ now: () => 1,
+ io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
+ request: async () => ({
+ status: 403,
+ text: '',
+ json: { success: false, error: message, errorCode: 'FORBIDDEN' },
+ }),
+ };
+ expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
+ expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
+ expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
+ expect(lines).toHaveLength(3);
+ for (const line of lines) {
+ const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
+ expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
+ }
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/test/cli-agent-project-spawn.test.ts b/test/cli-agent-project-spawn.test.ts
new file mode 100644
index 000000000..68f8f767c
--- /dev/null
+++ b/test/cli-agent-project-spawn.test.ts
@@ -0,0 +1,351 @@
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
+import http from 'node:http';
+import { tmpdir } from 'node:os';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { Command } from 'commander';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ agentSpawn,
+ isHomeDirectory,
+ registerAgentCommands,
+ EXIT,
+ type AgentDeps,
+ type ApiResponse,
+ type RequestOptions,
+} from '../src/cli-agent.js';
+
+const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
+const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
+const dirs: string[] = [];
+afterEach(async () => {
+ for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
+});
+const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' });
+function deps(responses: ApiResponse[]) {
+ const calls: RequestOptions[] = [];
+ const out: string[] = [];
+ const err: string[] = [];
+ const d: AgentDeps = {
+ ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' },
+ json: true,
+ now: Date.now,
+ io: { out: (s) => out.push(s), err: (s) => err.push(s) },
+ request: async (_ctx, options) => {
+ calls.push(options);
+ return responses.shift() ?? ok({});
+ },
+ };
+ return { d, calls, out, err };
+}
+const base = { mode: 'codex', ready: false, timeoutMs: 1000 };
+describe('project-preserving agent spawn', () => {
+ it('uses existing session create and interactive endpoints with exact directory and lineage', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
+ dirs.push(directory);
+ const t = deps([ok({ session: { id: OTHER } }), ok({})]);
+ expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok);
+ expect(t.calls).toHaveLength(2);
+ expect(t.calls[0]).toMatchObject({
+ method: 'POST',
+ path: '/api/v1/sessions',
+ body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF },
+ });
+ expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin');
+ expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` });
+ expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory });
+ });
+ it('defaults to the current directory when no case is supplied', async () => {
+ const t = deps([ok({ session: { id: OTHER } }), ok({})]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
+ expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() });
+ });
+ it('keeps explicit case quick-start behavior', async () => {
+ const t = deps([ok({ sessionId: OTHER })]);
+ expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok);
+ expect(t.calls).toHaveLength(1);
+ expect(t.calls[0].path).toBe('/api/v1/quick-start');
+ });
+ it('refuses case and working directory together before network IO', async () => {
+ const t = deps([]);
+ expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ });
+ it('refuses missing directories, regular files and unknown modes before creating a session', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
+ dirs.push(directory);
+ const file = join(directory, 'file');
+ await writeFile(file, 'x');
+ for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) {
+ const t = deps([]);
+ expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ }
+ });
+ it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => {
+ const t = deps([
+ ok({ session: { id: OTHER } }),
+ { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
+ ]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
+ expect(t.out).toHaveLength(1);
+ expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false });
+ expect(t.calls).toHaveLength(2);
+ });
+ it('does not request startup when creation is refused', async () => {
+ const t = deps([{ status: 403, text: 'Forbidden' }]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
+ expect(t.calls).toHaveLength(1);
+ });
+ it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => {
+ const t = deps([ok({ session: { id: OTHER } })]);
+ const request = t.d.request;
+ t.d.request = async (context, options) => {
+ if (options.path.endsWith('/interactive')) {
+ t.calls.push(options);
+ throw new Error('connection lost');
+ }
+ return request(context, options);
+ };
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
+ expect(t.out).toHaveLength(1);
+ expect(JSON.parse(t.out.join('\n'))).toMatchObject({
+ sessionId: OTHER,
+ created: true,
+ startupError: 'connection lost',
+ });
+ expect(t.calls).toHaveLength(2);
+ });
+ it('treats an empty startup exception as a failure and keeps the created ID', async () => {
+ const t = deps([ok({ session: { id: OTHER } })]);
+ const request = t.d.request;
+ t.d.request = async (context, options) => {
+ if (options.path.endsWith('/interactive')) throw new Error('');
+ return request(context, options);
+ };
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
+ expect(t.out).toHaveLength(1);
+ expect(JSON.parse(t.out[0])).toMatchObject({
+ sessionId: OTHER,
+ ready: false,
+ startupError: 'Startup request failed',
+ });
+ });
+ it('exposes an optional case and working directory in the CLI', () => {
+ const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!;
+ expect(spawn.registeredArguments[0].required).toBe(false);
+ expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true);
+ });
+ it('starts a shell through its registered startMode endpoint', async () => {
+ const t = deps([ok({ session: { id: OTHER } }), ok({})]);
+ expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok);
+ expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`);
+ });
+ it('a create answer without session.id is a failure, and nothing is started', async () => {
+ // `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route.
+ const t = deps([ok({ id: OTHER })]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
+ expect(t.calls).toHaveLength(1);
+ });
+ it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => {
+ const t = deps([
+ {
+ status: 400,
+ text: '',
+ json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' },
+ },
+ ]);
+ expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error);
+ expect(t.calls).toHaveLength(1);
+ expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } });
+ expect(t.calls[0].body).not.toHaveProperty('workingDir');
+ });
+ it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => {
+ const t = deps([]);
+ expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ });
+ it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => {
+ for (const workingDir of ['', ' ', '\t']) {
+ const t = deps([]);
+ expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ expect(t.out.join('\n')).toContain('--working-dir is empty');
+ }
+ });
+ it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => {
+ const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]);
+ expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok);
+ expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
+ 'POST /api/v1/sessions',
+ `POST /api/v1/sessions/${OTHER}/interactive`,
+ `GET /api/v1/sessions/${OTHER}/wait-output`,
+ ]);
+ expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' });
+ expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true });
+ });
+ it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => {
+ const t = deps([
+ ok({ session: { id: OTHER } }),
+ { status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
+ ]);
+ t.d.json = false;
+ expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error);
+ expect(t.out).toEqual([OTHER]);
+ expect(t.err).toHaveLength(1);
+ expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`);
+ expect(t.err[0]).toContain('cannot start');
+ expect(t.calls).toHaveLength(2); // no readiness wait after a failed start
+ });
+});
+
+describe('agent spawn through the real argument parser and transport', () => {
+ async function runSpawn(args: string[]) {
+ const seen: Array<{ method?: string; url?: string; body: unknown }> = [];
+ const server = http.createServer((req, res) => {
+ const chunks: Buffer[] = [];
+ req.on('data', (c: Buffer) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString();
+ seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined });
+ res
+ .writeHead(400, { 'Content-Type': 'application/json' })
+ .end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' }));
+ });
+ });
+ await new Promise((done) => server.listen(0, '127.0.0.1', done));
+ const { port } = server.address() as { port: number };
+ const saved = { ...process.env };
+ const savedExit = process.exitCode;
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ Object.assign(process.env, {
+ CODEMAN_MUX: '1',
+ CODEMAN_API_URL: `http://127.0.0.1:${port}`,
+ CODEMAN_SESSION_ID: SELF,
+ });
+ delete process.env.CODEMAN_PASSWORD;
+ const program = new Command();
+ program.exitOverride();
+ registerAgentCommands(program);
+ await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']);
+ return { seen, exitCode: process.exitCode };
+ } finally {
+ process.exitCode = savedExit;
+ for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+ log.mockRestore();
+ error.mockRestore();
+ await new Promise((done) => server.close(() => done()));
+ }
+ }
+
+ it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => {
+ const { seen, exitCode } = await runSpawn(['']);
+ expect(seen).toHaveLength(1);
+ expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } });
+ expect(exitCode).toBe(EXIT.error);
+ });
+ it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => {
+ for (const value of ['', ' ']) {
+ const { seen, exitCode } = await runSpawn(['--working-dir', value]);
+ expect(seen).toEqual([]);
+ expect(exitCode).toBe(EXIT.refused);
+ }
+ });
+ it('`spawn` with no case argument creates in the caller cwd', async () => {
+ const { seen, exitCode } = await runSpawn([]);
+ expect(seen).toHaveLength(1);
+ expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } });
+ expect(exitCode).toBe(EXIT.error);
+ });
+ it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir ` is sent', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'cod549-home-'));
+ dirs.push(home);
+ vi.stubEnv('HOME', home);
+ const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home);
+ try {
+ const bare = await runSpawn([]);
+ expect(bare.seen).toEqual([]);
+ expect(bare.exitCode).toBe(EXIT.refused);
+ const explicit = await runSpawn(['--working-dir', home]);
+ expect(explicit.seen).toHaveLength(1);
+ expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } });
+ } finally {
+ cwd.mockRestore();
+ vi.unstubAllEnvs();
+ }
+ });
+});
+
+describe('agent spawn never treats an implicit HOME cwd as a project', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+ async function homeDir(): Promise {
+ const directory = await mkdtemp(join(tmpdir(), 'cod549-home-'));
+ dirs.push(directory);
+ return directory;
+ }
+
+ it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => {
+ const home = await homeDir();
+ vi.stubEnv('HOME', home);
+ vi.spyOn(process, 'cwd').mockReturnValue(home);
+ const t = deps([]);
+ expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~');
+ });
+ it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => {
+ const real = await homeDir();
+ const link = join(await homeDir(), 'home-link');
+ await symlink(real, link);
+ vi.stubEnv('HOME', link);
+ vi.spyOn(process, 'cwd').mockReturnValue(real);
+ const t = deps([]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.refused);
+ expect(t.calls).toEqual([]);
+ });
+ it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => {
+ const home = await homeDir();
+ vi.stubEnv('HOME', home);
+ vi.spyOn(process, 'cwd').mockReturnValue(home);
+ const t = deps([ok({ session: { id: OTHER } }), ok({})]);
+ expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok);
+ expect(t.calls[0].body).toMatchObject({ workingDir: home });
+ });
+ it('allows a cwd below HOME', async () => {
+ const home = await homeDir();
+ const project = join(home, 'src', 'app');
+ await mkdir(project, { recursive: true });
+ vi.stubEnv('HOME', home);
+ vi.spyOn(process, 'cwd').mockReturnValue(project);
+ const t = deps([ok({ session: { id: OTHER } }), ok({})]);
+ expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
+ expect(t.calls[0].body).toMatchObject({ workingDir: project });
+ });
+ it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => {
+ const missing = join(await homeDir(), 'absent');
+ vi.stubEnv('HOME', missing);
+ await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
+ await expect(isHomeDirectory(missing)).resolves.toBe(true);
+ vi.stubEnv('HOME', '');
+ await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
+ });
+});
+
+describe('the directory-spawn --json shape is documented the same way everywhere', () => {
+ // The wiki wraps lines, so whitespace is normalized before comparing.
+ const clause =
+ '`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed';
+ for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) {
+ it(file, () => {
+ expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause);
+ });
+ }
+});
diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts
index c6331aadc..7f5796968 100644
--- a/test/cli-agent.test.ts
+++ b/test/cli-agent.test.ts
@@ -30,6 +30,7 @@ import {
buildSendBody,
deleteRefusal,
describeFailure,
+ describeRequestError,
httpRequest,
inboxCell,
inputRefusal,
@@ -548,6 +549,75 @@ describe('agent spawn', () => {
expect(deps.calls).toHaveLength(1);
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
});
+
+ it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => {
+ const deps = fakeDeps((options) => {
+ if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' });
+ throw new Error('readiness connection reset');
+ }, json);
+ const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch(
+ () => undefined
+ );
+
+ if (json) {
+ expect(deps.out).toHaveLength(1);
+ expect(JSON.parse(deps.out[0])).toEqual({
+ sessionId: OTHER,
+ caseName: 'c',
+ readinessError: 'readiness connection reset',
+ });
+ expect(deps.err).toEqual([]);
+ } else {
+ expect(deps.out).toEqual([OTHER]);
+ expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset');
+ expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/);
+ }
+ expect(exit).toBe(EXIT.error);
+ expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([
+ { method: 'POST', path: '/api/v1/quick-start' },
+ { method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` },
+ ]);
+ });
+
+ it.each([
+ { error: new Error(''), kind: 'Error' },
+ { error: '', kind: 'string' },
+ ])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => {
+ const deps = fakeDeps((options) => {
+ if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
+ throw error;
+ }, true);
+
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
+ expect(deps.out).toHaveLength(1);
+ expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' });
+ expect(deps.calls).toHaveLength(2);
+ });
+
+ it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => {
+ // Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED.
+ const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), {
+ code: 'ECONNREFUSED',
+ });
+ const deps = fakeDeps((options) => {
+ if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
+ throw refused;
+ }, true);
+
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
+ expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' });
+ });
+});
+
+describe('describeRequestError', () => {
+ it('prefers the message, then the code, then a generic line', () => {
+ expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up');
+ expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe(
+ 'ECONNREFUSED'
+ );
+ expect(describeRequestError(new Error(''))).toBe('request failed without a message');
+ expect(describeRequestError('')).toBe('request failed without a message');
+ });
});
describe('session id prefixes', () => {
diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts
index 9714313e7..689907851 100644
--- a/test/cli-commands.test.ts
+++ b/test/cli-commands.test.ts
@@ -208,3 +208,29 @@ describe('help text', () => {
}
});
});
+
+describe('unauthenticated tunnel launch option', () => {
+ it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => {
+ const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!;
+ const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel');
+ expect(
+ options.map((option) => ({
+ flags: option.flags,
+ attribute: option.attributeName(),
+ required: option.required,
+ optional: option.optional,
+ }))
+ ).toEqual([
+ {
+ flags: '--allow-unauthenticated-tunnel',
+ attribute: 'allowUnauthenticatedTunnel',
+ required: false,
+ optional: false,
+ },
+ ]);
+ });
+
+ it('documents --allow-unauthenticated-tunnel in web help', () => {
+ expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel');
+ });
+});
diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts
index 9fa1c6ee5..d0fa5f5b6 100644
--- a/test/cli-registry-schema.test.ts
+++ b/test/cli-registry-schema.test.ts
@@ -184,6 +184,69 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
}
});
+ it('holds the optional composerFooterLine to the same guard', () => {
+ // Run over the rows under the composer each time guarded input reads the pane.
+ for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
+ expectRejected((e) => {
+ (e.capabilities as Record).workDetect = {
+ promptGlyph: '>',
+ workingLine: 'working',
+ composerFooterLine,
+ };
+ }, 'guarded input tests it against pane rows a clis.json override can supply');
+ }
+ });
+
+ it('accepts every shipped composerFooterLine', () => {
+ for (const entry of STOCK_CLIS) {
+ const src = entry.capabilities.workDetect?.composerFooterLine;
+ if (!src) continue;
+ expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
+ }
+ });
+
+ it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
+ // Run over the rows just above the composer each time guarded input reads the pane.
+ for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
+ expectRejected((e) => {
+ (e.capabilities as Record).workDetect = {
+ promptGlyph: '>',
+ workingLine: 'working',
+ composerPopupLine,
+ };
+ }, 'guarded input tests it against pane rows a clis.json override can supply');
+ }
+ for (const entry of STOCK_CLIS) {
+ const src = entry.capabilities.workDetect?.composerPopupLine;
+ if (src)
+ expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
+ }
+ });
+
+ it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
+ for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
+ for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
+ expectRejected(
+ (e) => {
+ (e.capabilities as Record).workDetect = {
+ promptGlyph: '>',
+ workingLine: 'working',
+ [field]: value,
+ };
+ },
+ `${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
+ );
+ }
+ const entry = baseEntry();
+ (entry.capabilities as Record).workDetect = {
+ promptGlyph: '>',
+ workingLine: 'working',
+ [field]: '/!@$',
+ };
+ expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
+ }
+ });
+
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
@@ -485,6 +548,30 @@ describe('capability shapes', () => {
}, 'transcriptPageKeys is a boolean');
});
+ it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
+ const inline = baseEntry();
+ (inline.capabilities as Record).keyboardHold = 'inline';
+ expect(CliEntrySchema.safeParse(inline).success).toBe(true);
+ const absent = baseEntry();
+ delete (absent.capabilities as Record).keyboardHold;
+ expect(CliEntrySchema.safeParse(absent).success).toBe(true);
+ for (const value of [true, 'fullscreen', '']) {
+ expectRejected(
+ (e) => {
+ (e.capabilities as Record).keyboardHold = value;
+ },
+ `keyboardHold=${JSON.stringify(value)} names no renderer kind`
+ );
+ }
+ });
+
+ it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
+ // Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
+ // tmux on keyboard-driven height changes; every other stock entry resizes.
+ const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
+ expect(holding).toEqual(['claude']);
+ });
+
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
diff --git a/test/cli-tunnel-flag.test.ts b/test/cli-tunnel-flag.test.ts
new file mode 100644
index 000000000..575be2ca5
--- /dev/null
+++ b/test/cli-tunnel-flag.test.ts
@@ -0,0 +1,102 @@
+/**
+ * @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const launches = vi.hoisted(() => ({
+ startDaemon: vi.fn(),
+ startWebServer: vi.fn(),
+ installService: vi.fn(),
+ stop: vi.fn(),
+}));
+
+vi.mock('../src/daemon-control.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ startDaemon: launches.startDaemon,
+}));
+vi.mock('../src/service-installer.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ installService: launches.installService,
+}));
+vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer }));
+
+const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
+let originalListeners: Map>;
+let exitSpy: ReturnType>;
+
+beforeEach(() => {
+ // Commander retains parsed option values; use a fresh real program for each invocation.
+ vi.resetModules();
+ vi.clearAllMocks();
+ originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)]));
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
+ throw new Error(`Unexpected process.exit(${code})`);
+ });
+ launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' });
+ launches.stop.mockResolvedValue(undefined);
+ launches.startWebServer.mockResolvedValue({ stop: launches.stop });
+ launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' });
+});
+
+afterEach(() => {
+ for (const signal of SIGNALS) {
+ for (const listener of process.listeners(signal)) {
+ if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener);
+ }
+ }
+ vi.restoreAllMocks();
+});
+
+const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({
+ host: '127.0.0.1',
+ port: 8080,
+ https: false,
+ basePath: '',
+ titleHostname: undefined,
+ allowUnauthenticatedNetwork: false,
+ allowUnauthenticatedTunnel,
+ multiuser: false,
+});
+
+async function parse(args: string[], enabled: boolean) {
+ const { program } = await import('../src/cli.js');
+ await program.parseAsync([
+ 'node',
+ 'codeman',
+ ...args,
+ '--port',
+ '8080',
+ '--host',
+ '127.0.0.1',
+ '--base-url',
+ '/',
+ ...(enabled ? ['--allow-unauthenticated-tunnel'] : []),
+ ]);
+ expect(exitSpy).not.toHaveBeenCalled();
+}
+
+describe('tunnel flag CLI plumbing', () => {
+ it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => {
+ await parse(['web', '-d'], enabled);
+ expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]);
+ expect(launches.startWebServer.mock.calls).toEqual([]);
+ expect(launches.installService.mock.calls).toEqual([]);
+ });
+
+ it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => {
+ await parse(['web'], enabled);
+ expect(launches.startWebServer.mock.calls).toEqual([
+ [8080, false, false, '127.0.0.1', undefined, false, '', enabled],
+ ]);
+ expect(launches.startDaemon.mock.calls).toEqual([]);
+ expect(launches.installService.mock.calls).toEqual([]);
+ expect(launches.stop.mock.calls).toEqual([]);
+ });
+
+ it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => {
+ await parse(['service', 'install'], enabled);
+ expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]);
+ expect(launches.startDaemon.mock.calls).toEqual([]);
+ expect(launches.startWebServer.mock.calls).toEqual([]);
+ });
+});
diff --git a/test/codex-rollout-format.test.ts b/test/codex-rollout-format.test.ts
new file mode 100644
index 000000000..761f32b83
--- /dev/null
+++ b/test/codex-rollout-format.test.ts
@@ -0,0 +1,112 @@
+/**
+ * @fileoverview The Codex rollout shape helpers shared by the Past Sessions
+ * scanner (codex-transcript.ts) and the offline history reader
+ * (local-history.ts): store location, message shapes and the injected-context
+ * rule. Both readers' own tests cover their use of these; this pins the shared
+ * contract itself.
+ */
+import { afterEach, describe, expect, it } from 'vitest';
+import { join } from 'node:path';
+import {
+ codexContentText,
+ codexHomeDir,
+ codexMessageFromLine,
+ codexSessionsRoot,
+ isCodexInjectedContext,
+} from '../src/codex-rollout-format.js';
+
+const savedHome = process.env.CODEX_HOME;
+afterEach(() => {
+ if (savedHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = savedHome;
+});
+
+describe('codex rollout format', () => {
+ it('resolves the store from CODEX_HOME, else ~/.codex', () => {
+ process.env.CODEX_HOME = '/custom/codex';
+ expect(codexHomeDir()).toBe('/custom/codex');
+ expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions'));
+ process.env.CODEX_HOME = '';
+ expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions'));
+ });
+
+ it('reads all three message shapes with their role, shape and phase', () => {
+ expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({
+ role: 'user',
+ text: 'hi',
+ shape: 'event',
+ phase: undefined,
+ injectionProne: false,
+ });
+ expect(
+ codexMessageFromLine({
+ type: 'event_msg',
+ payload: { type: 'agent_message', message: 'ok', phase: 'commentary' },
+ })
+ ).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false });
+ expect(
+ codexMessageFromLine({
+ type: 'event_msg',
+ payload: {
+ type: 'item_completed',
+ item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] },
+ },
+ })
+ ).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' });
+ expect(
+ codexMessageFromLine({
+ type: 'response_item',
+ payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] },
+ })
+ ).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true });
+ expect(
+ codexMessageFromLine({
+ type: 'response_item',
+ payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] },
+ })
+ ).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false });
+ });
+
+ it('ignores every other line, empty text and malformed input', () => {
+ for (const value of [
+ null,
+ 42,
+ 'text',
+ [],
+ { type: 'session_meta', payload: { id: 'x' } },
+ { type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } },
+ { type: 'response_item', payload: { type: 'reasoning', summary: [] } },
+ { type: 'event_msg', payload: { type: 'user_message', message: '' } },
+ { type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } },
+ ])
+ expect(codexMessageFromLine(value)).toBeNull();
+ });
+
+ it('flattens only blocks that carry text', () => {
+ expect(codexContentText('plain')).toBe('plain');
+ expect(
+ codexContentText([
+ { type: 'input_text', text: 'a' },
+ { type: 'input_image', image_url: 'x' },
+ { text: 'b' },
+ null,
+ ])
+ ).toBe('a\nb');
+ expect(codexContentText(undefined)).toBe('');
+ });
+
+ it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => {
+ for (const text of [
+ '# AGENTS.md instructions for /repo',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ' ',
+ ])
+ expect(isCodexInjectedContext(text)).toBe(true);
+ for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a tag later'])
+ expect(isCodexInjectedContext(text)).toBe(false);
+ });
+});
diff --git a/test/command-palette-agent-arrows.browser.test.ts b/test/command-palette-agent-arrows.browser.test.ts
new file mode 100644
index 000000000..182f4e234
--- /dev/null
+++ b/test/command-palette-agent-arrows.browser.test.ts
@@ -0,0 +1,86 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { chromium, type Browser } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+describe('Command-K agent arrow keys in Chromium', () => {
+ let server: WebServer;
+ let browser: Browser;
+ beforeAll(async () => {
+ server = new WebServer(0, false, true);
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+ }, 60000);
+ afterAll(async () => {
+ await browser?.close();
+ await server?.stop();
+ });
+
+ it('cycles both directions at query boundaries while preserving text editing and selection', async () => {
+ const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
+ const errors: string[] = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ try {
+ await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => (window as any).app?.openCommandPalette);
+ await page.waitForTimeout(3500);
+ await page.evaluate(() => {
+ const a = (window as any).app;
+ a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }];
+ a.sessions = new Map();
+ a.sessionOrder = [];
+ a._runMode = 'codex';
+ (window as any).__codemanCliCatalog = [
+ { id: 'claude', label: 'Claude', enabled: true, kind: 'agent' },
+ { id: 'codex', label: 'Codex', enabled: true, kind: 'agent' },
+ { id: 'shell', label: 'Shell', enabled: true, kind: 'shell' },
+ ];
+ a._runModeAvailableForCase = () => true;
+ a._sendInputEphemeral = () => {
+ throw new Error('Palette leaked terminal input');
+ };
+ a.run = () => {
+ throw new Error('Arrow key launched a session');
+ };
+ });
+ await page.keyboard.press('Control+k');
+ await page.locator('#commandPaletteModal.active').waitFor();
+ const input = page.locator('#commandPaletteSearch');
+ await input.fill('buildmesh');
+ const label = page.locator('.command-palette-agent-label');
+ expect(await label.innerText()).toBe('Codex');
+ await page.keyboard.press('ArrowLeft');
+ expect(await label.innerText()).toBe('Claude');
+ await page.keyboard.press('ArrowRight');
+ expect(await label.innerText()).toBe('Codex');
+ expect(await input.inputValue()).toBe('buildmesh');
+ expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9);
+ await page.keyboard.press('Home');
+ await page.keyboard.press('ArrowRight');
+ expect(await label.innerText()).toBe('Shell');
+ await page.keyboard.press('ArrowLeft');
+ expect(await label.innerText()).toBe('Codex');
+ await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4));
+ await page.keyboard.press('ArrowLeft');
+ expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3);
+ await page.keyboard.press('ArrowRight');
+ expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4);
+ await page.keyboard.press('Shift+End');
+ await page.keyboard.press('ArrowLeft');
+ expect(await label.innerText()).toBe('Codex');
+ await page.keyboard.press('ArrowDown');
+ expect(
+ await page.evaluate(
+ () => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type
+ )
+ ).toBe('browse-sessions');
+ await page.keyboard.press('End');
+ await page.keyboard.press('ArrowLeft');
+ expect(await label.innerText()).toBe('Codex');
+ await page.keyboard.press('Escape');
+ expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false);
+ expect(errors).toEqual([]);
+ } finally {
+ await page.close();
+ }
+ });
+});
diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts
index 408a4e1d6..516a11dcd 100644
--- a/test/command-palette-ui.test.ts
+++ b/test/command-palette-ui.test.ts
@@ -437,13 +437,28 @@ describe('Command-K new-session agent picker', () => {
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
- it('leaves Left/Right to the caret when they would move it', async () => {
+ it('cycles both ways at the end of a typed case query without moving its caret', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
-
const left = arrow('ArrowLeft', 7, 'nomatch');
+ await listeners['search:keydown'](left);
+ expect(left.preventDefault).toHaveBeenCalled();
+ expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode');
+ const right = arrow('ArrowRight', 7, 'nomatch');
+ await listeners['search:keydown'](right);
+ expect(right.preventDefault).toHaveBeenCalled();
+ expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
+ });
+
+ it('leaves Left/Right to the caret inside the query', async () => {
+ const { app, listeners, elements } = loadAgentPalette();
+ app.openCommandPalette();
+ elements.commandPaletteSearch.value = 'nomatch';
+ app.renderCommandPalette();
+
+ const left = arrow('ArrowLeft', 3, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
@@ -453,6 +468,20 @@ describe('Command-K new-session agent picker', () => {
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
+ it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
+ 'preserves %s arrow editing at query boundaries',
+ async (modifier) => {
+ const { app, listeners, elements } = loadAgentPalette();
+ app.openCommandPalette();
+ elements.commandPaletteSearch.value = 'nomatch';
+ app.renderCommandPalette();
+ const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true };
+ await listeners['search:keydown'](event);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
+ }
+ );
+
it('does not cycle while a session row is highlighted', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts
index 54c19ff9a..3aefd40c4 100644
--- a/test/daemon-control.test.ts
+++ b/test/daemon-control.test.ts
@@ -30,6 +30,36 @@ async function closedPort(): Promise {
}
describe('buildWebArgs', () => {
+ it('forwards the tunnel opt-in between the network and multi-user flags', () => {
+ const options = {
+ host: '0.0.0.0',
+ port: 8080,
+ https: true,
+ titleHostname: 'tower',
+ allowUnauthenticatedNetwork: true,
+ allowUnauthenticatedTunnel: true,
+ multiuser: true,
+ };
+ expect(buildWebArgs(options)).toEqual([
+ 'web',
+ '--host',
+ '0.0.0.0',
+ '--port',
+ '8080',
+ '--https',
+ '--title-hostname',
+ 'tower',
+ '--allow-unauthenticated-network',
+ '--allow-unauthenticated-tunnel',
+ '--multiuser',
+ ]);
+ });
+
+ it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => {
+ const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel };
+ expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']);
+ });
+
it('always passes host and port through explicitly', () => {
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
'web',
diff --git a/test/deepseek-transcript.test.ts b/test/deepseek-transcript.test.ts
index e0837070d..292aac14c 100644
--- a/test/deepseek-transcript.test.ts
+++ b/test/deepseek-transcript.test.ts
@@ -4,10 +4,11 @@
* Two of these assertions exist because the obvious implementation was measured
* to be wrong against real files:
*
- * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops
- * at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes,
- * which reads as "the worker never answered" rather than as an error. The
- * multi-frame fixtures below are the guard.
+ * - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder
+ * before 24.22 and 26.10 stops at the first frame end. A 56-line transcript
+ * decoded as 1 line / 158 bytes, which reads as "the worker never answered"
+ * rather than as an error. Newer Node decodes every frame (nodejs/node#65865),
+ * so the multi-frame fixtures below pin the walker itself, not Node's decoder.
* - a fresh worker in a case directory that had been used before answered its
* first `last-response` with the PREVIOUS session's reply. Session-to-
* transcript pairing is therefore its own describe block.
@@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => {
const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i }));
const buf = framed(lines);
- // The one-shot decoder is what this module had to replace.
- const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf);
- expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1);
-
expect(zstdFrameRanges(buf)).toHaveLength(40);
expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40);
});
diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts
index 123c73ec5..8913b64a8 100644
--- a/test/docker-hosts.test.ts
+++ b/test/docker-hosts.test.ts
@@ -28,6 +28,7 @@ import {
persistDockerCaseClaudeSessionId,
probeDockerCliVersion,
readDockerCases,
+ readDockerCasesOrThrow,
readDockerHosts,
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
@@ -70,6 +71,28 @@ describe('docker-hosts storage', () => {
expect(await readDockerCases(dir)).toEqual([]);
});
+ /**
+ * The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in
+ * place, and a list read as `[]` there would let a fetch run git inside a Docker workspace.
+ */
+ it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => {
+ expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases
+ await writeDockerCases(dir, [CASE]);
+ expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]);
+ const path = join(dir, 'docker-cases.json');
+ for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) {
+ writeFileSync(path, text);
+ expect(await readDockerCases(dir)).toEqual([]);
+ const err = await readDockerCasesOrThrow(dir).then(
+ () => null,
+ (e: Error) => e
+ );
+ expect(err?.message).toMatch(/not a valid JSON array/);
+ // The parser's message quotes the file; the strict reader's never does.
+ expect(err?.message).not.toContain('/home/arkon/cas');
+ }
+ });
+
it('persists the last Claude conversation id keyed by container name', async () => {
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
diff --git a/test/fixtures/codex-rollout.ts b/test/fixtures/codex-rollout.ts
new file mode 100644
index 000000000..9f90f5c55
--- /dev/null
+++ b/test/fixtures/codex-rollout.ts
@@ -0,0 +1,125 @@
+/**
+ * @fileoverview Codex rollout lines shaped like real ones, for the offline
+ * history tests. The structure (keys, their order, the event types around a
+ * fork boundary) was taken from real rollouts written by codex 0.120 to 0.163
+ * and from codex-rs `SessionMeta` (`protocol/src/protocol.rs`); no
+ * conversation content was copied.
+ *
+ * A forked or spawned thread's file, as `record_initial_history` writes it for
+ * `ForkPersistence::Copied`:
+ *
+ * own session_meta, parent session_meta, copied parent records,
+ * thread_settings_applied, the child's own records
+ *
+ * In paginated history every line also carries an `ordinal`, and the child's
+ * own `session_meta` declares `subagent_history_start_ordinal`.
+ */
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+/** Codex's base instructions make every real session_meta line about 20 KiB. */
+export const BASE_INSTRUCTIONS_BYTES = 19 * 1024;
+
+export interface MetaOptions {
+ id: string;
+ /** Spawn parent: sets parent_thread_id, forked_from_id and source.subagent.thread_spawn. */
+ parent?: string;
+ /** Shared root (`session_id`); defaults to `parent ?? id`, as Codex writes it. */
+ root?: string;
+ mode?: 'legacy' | 'paginated';
+ historyStart?: number;
+ timestamp?: string;
+ baseInstructionsBytes?: number;
+}
+
+export function realMeta(options: MetaOptions): Record {
+ const { id, parent, mode = 'legacy', timestamp = '2026-10-24T01:00:00.000Z' } = options;
+ const payload: Record = {
+ session_id: options.root ?? parent ?? id,
+ id,
+ ...(parent ? { forked_from_id: parent, parent_thread_id: parent } : {}),
+ timestamp,
+ cwd: '/work/repo',
+ originator: 'codex_exec',
+ cli_version: '0.163.0',
+ source: parent
+ ? {
+ subagent: {
+ thread_spawn: {
+ parent_thread_id: parent,
+ depth: 1,
+ agent_path: null,
+ agent_nickname: 'Kepler',
+ agent_role: null,
+ },
+ },
+ }
+ : 'exec',
+ thread_source: parent ? 'subagent' : 'user',
+ ...(parent ? { agent_nickname: 'Kepler' } : {}),
+ model_provider: 'openai',
+ base_instructions: { text: 'b'.repeat(options.baseInstructionsBytes ?? BASE_INSTRUCTIONS_BYTES) },
+ history_mode: mode,
+ ...(options.historyStart !== undefined ? { subagent_history_start_ordinal: options.historyStart } : {}),
+ ...(parent ? { multi_agent_version: 'v1' } : {}),
+ context_window: { window_id: `${id.slice(0, 8)}-window` },
+ };
+ return { timestamp, type: 'session_meta', payload };
+}
+
+export const taskStarted = () => ({
+ timestamp: '2026-10-24T01:00:00.000Z',
+ type: 'event_msg',
+ payload: { type: 'task_started' },
+});
+export const settingsApplied = () => ({
+ timestamp: '2026-10-24T01:00:00.000Z',
+ type: 'event_msg',
+ payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.5', cwd: '/work/repo' } },
+});
+export const developer = () => ({
+ timestamp: '2026-10-24T01:00:00.000Z',
+ type: 'response_item',
+ payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'policy' }] },
+});
+export const injected = (tag = 'environment_context') => ({
+ timestamp: '2026-10-24T01:00:00.000Z',
+ type: 'response_item',
+ payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `<${tag}>\n ctx\n${tag}>` }] },
+});
+/** A real user turn: the response_item Codex sent plus its user_message event twin. */
+export const userTurn = (text: string, timestamp = '2026-10-24T01:00:00.000Z') => [
+ {
+ timestamp,
+ type: 'response_item',
+ payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
+ },
+ { timestamp, type: 'event_msg', payload: { type: 'user_message', message: text, images: [] } },
+];
+/** An assistant message: the event and the response_item, both carrying Codex's phase. */
+export const assistantTurn = (text: string, phase: 'commentary' | 'final_answer' = 'final_answer') => [
+ {
+ timestamp: '2026-10-24T01:00:01.000Z',
+ type: 'event_msg',
+ payload: { type: 'agent_message', message: text, phase },
+ },
+ {
+ timestamp: '2026-10-24T01:00:01.000Z',
+ type: 'response_item',
+ payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], phase },
+ },
+];
+
+/** Number every line, as paginated history does. */
+export function withOrdinals(lines: Array>): Array> {
+ return lines.map((line, ordinal) => {
+ const { timestamp, type, payload, ...rest } = line;
+ return { timestamp, ordinal, type, payload, ...rest };
+ });
+}
+
+export async function writeRollout(dir: string, name: string, lines: unknown[]): Promise {
+ const path = join(dir, name);
+ await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
+ return path;
+}
diff --git a/test/git-fetch-i18n.test.ts b/test/git-fetch-i18n.test.ts
new file mode 100644
index 000000000..71ecdbefb
--- /dev/null
+++ b/test/git-fetch-i18n.test.ts
@@ -0,0 +1,101 @@
+/**
+ * @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese
+ * (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and
+ * reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and
+ * every new key (the save toast in session-ui.js included) is in the dictionary exactly once (a repeated key
+ * in the object literal silently replaces the first).
+ *
+ * The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries
+ * data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either: only
+ * whole unit words are ('hours'), so 'sec' stays as written.
+ *
+ * Port: N/A.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, describe, expect, it } from 'vitest';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
+const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
+// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped.
+const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"');
+
+type Api = { configure(o: Record): void; start(): void };
+
+const ROWS = [
+ 'appSettingsGitFetchEnabled',
+ 'appSettingsGitFetchIntervalSeconds',
+ 'modalGitFetchFollow',
+ 'modalGitFetchEnabled',
+ 'modalGitFetchInterval',
+];
+
+/** What may stay Latin: the product names, and Git / git fetch as technical names. */
+const leftover = (text: string) =>
+ text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? [];
+
+function rowStrings(doc: Document, id: string): { label: string; description: string } {
+ const row = doc.getElementById(id)!.closest('.set-row')!;
+ const label = row.querySelector('.set-row-label')!;
+ // The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it.
+ const scope = label.querySelector('.set-scope')?.textContent ?? '';
+ return {
+ label: label.textContent!.replace(scope, '').trim(),
+ description: row.querySelector('.set-row-desc')!.textContent!.trim(),
+ };
+}
+
+/** The Git section's blurb, which is not a row. */
+function blurb(doc: Document): string {
+ const section = doc.getElementById('git-tab')!;
+ return section.querySelector('.set-section-blurb')!.textContent!.trim();
+}
+
+const source = new JSDOM(INDEX);
+const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
+vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
+const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
+api.start();
+api.configure({ language: 'zh-CN' });
+
+afterAll(() => {
+ source.window.close();
+ dom.window.close();
+});
+
+describe('the Git fetch rows in zh-CN', () => {
+ it.each(ROWS)('translates the %s row, with no English left', (id) => {
+ const english = rowStrings(source.window.document, id);
+ const chinese = rowStrings(dom.window.document, id);
+ expect(english.label.length).toBeGreaterThan(0);
+ for (const key of ['label', 'description'] as const) {
+ expect(chinese[key]).not.toBe(english[key]);
+ expect(leftover(chinese[key])).toEqual([]);
+ }
+ });
+
+ it('translates the Git section blurb too, which is not a row', () => {
+ expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document));
+ expect(leftover(blurb(dom.window.document))).toEqual([]);
+ });
+
+ it("leaves the Status row's runtime text to the code that writes it", () => {
+ // updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp.
+ expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden');
+ });
+
+ it('adds each key to the dictionary once, the save toast included', () => {
+ const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
+ texts.push(blurb(source.window.document), 'Git fetch settings saved');
+ // 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it.
+ expect(DICT).not.toMatch(/\n sec: /);
+ for (const text of texts) {
+ const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2;
+ const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0;
+ expect(quoted + bare, text).toBe(1);
+ }
+ });
+});
diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts
new file mode 100644
index 000000000..3c7add065
--- /dev/null
+++ b/test/git-fetch-scheduler.test.ts
@@ -0,0 +1,550 @@
+/**
+ * @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs,
+ * and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here
+ * depends on fake timers or on how long a real tick takes: the scheduler owns no timers itself.
+ *
+ * The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that
+ * both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole
+ * tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session
+ * open on it is due, so the order the sessions were read cannot decide it, and that is checked in both orders.
+ * A run carries the trigger that caused it, and what is published says the run's, not the tick's.
+ *
+ * Port: N/A.
+ */
+import { mkdir, rm, writeFile } from 'node:fs/promises';
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ GitFetchScheduler,
+ effectiveGitFetch,
+ type GitFetchPorts,
+ type GitFetchSessionView,
+} from '../src/git-fetch-scheduler.js';
+import {
+ FETCH_CONCURRENCY,
+ type GitFetchOptions,
+ type GitFetchReport,
+ type GitFetchRun,
+ type GitFetchSettings,
+ type GitFetchStatus,
+ type GitFetchTrigger,
+} from '../src/git-fetch.js';
+import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js';
+import type { PathProbeKind } from '../src/utils/bounded-path-probe.js';
+import { readGitFetchSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js';
+import { SETTINGS_PATH } from '../src/web/route-helpers.js';
+
+const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 };
+const VIEW = (id: string, extra: Partial = {}): GitFetchSessionView => ({
+ id,
+ workingDir: `/w/${id}`,
+ ...extra,
+});
+
+/** An empty report, in the shape a run produces. */
+const report = (cwd: string, name: string): GitFetchReport => ({
+ state: 'ok',
+ repos: [{ name, path: '.', ok: true }],
+ reposTruncated: false,
+ fetchedAt: Date.now(),
+});
+
+/** A run: the report plus the trigger that caused it, which is what the published row has to name. */
+const run = (cwd: string, name: string, trigger: GitFetchTrigger = 'interval'): GitFetchRun => ({
+ report: report(cwd, name),
+ trigger,
+});
+
+type Harness = {
+ scheduler: GitFetchScheduler;
+ ports: GitFetchPorts;
+ /** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */
+ calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>;
+ published: Array<{ id: string; status: GitFetchStatus }>;
+ /** Every path the tick probed, in order: the probe has to happen before anything is fetched. */
+ probed: string[];
+ settings: GitFetchSettings;
+ dockerReads: number;
+ /**
+ * A session that closed: the server calls `remove()` when the session is gone, so this takes it out of the
+ * ports as well as out of the scheduler. Otherwise a test that removes a session still sees it on the next
+ * tick, and the folder it was on looks still open.
+ */
+ remove(id: string): void;
+};
+
+/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */
+function deferred(value: T) {
+ let resolve = (v: T) => {};
+ const promise = new Promise((r) => (resolve = r));
+ return { promise, resolve };
+}
+
+/**
+ * A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the
+ * server, so what is being pinned is the scheduler's own decisions.
+ */
+function harness(
+ sessions: GitFetchSessionView[],
+ opts: {
+ settings?: GitFetchSettings;
+ fetch?: (cwd: string, portOpts: GitFetchOptions, trigger: GitFetchTrigger) => Promise;
+ /** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */
+ probe?: (path: string) => Promise;
+ } = {}
+): Harness {
+ const customFetch = opts.fetch;
+ const settings = opts.settings ?? { ...SETTINGS };
+ const gone = new Set();
+ const h: Harness = {
+ scheduler: undefined as unknown as GitFetchScheduler,
+ ports: undefined as unknown as GitFetchPorts,
+ calls: [],
+ published: [],
+ probed: [],
+ settings,
+ dockerReads: 0,
+ remove: (id) => {
+ gone.add(id);
+ h.scheduler.remove(id);
+ },
+ };
+ h.ports = {
+ sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id),
+ getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)),
+ readSettings: async () => {
+ if (h.settings === null) throw new Error('settings.json is not valid JSON');
+ return h.settings;
+ },
+ dockerWorkspaces: async () => {
+ h.dockerReads++;
+ return [];
+ },
+ probePath: async (path) => {
+ h.probed.push(path);
+ return opts.probe ? await opts.probe(path) : 'directory';
+ },
+ fetch: async (cwd, portOpts, trigger) => {
+ h.calls.push({ cwd, opts: portOpts, trigger });
+ return customFetch ? customFetch(cwd, portOpts, trigger) : run(cwd, cwd.slice(3), trigger);
+ },
+ publish: (id, status) => h.published.push({ id, status }),
+ };
+ h.scheduler = new GitFetchScheduler(h.ports);
+ return h;
+}
+
+describe('a quiet tick', () => {
+ it('costs one settings read and nothing else, because nothing is on', async () => {
+ const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } });
+ expect(await h.scheduler.tick(1000)).toBe(0);
+ expect(h.calls).toEqual([]);
+ // The probe and the Docker case list are only worth reading once something is actually due.
+ expect(h.probed).toEqual([]);
+ expect(h.dockerReads).toBe(0);
+ });
+
+ it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => {
+ const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } });
+ await h.scheduler.tick(0);
+ expect(h.calls.length).toBe(0);
+
+ h.settings = { enabled: true, intervalSeconds: 30 };
+ await h.scheduler.tick(1000);
+ expect(h.calls.length).toBe(1);
+
+ // Turning it off has to stop the fetching, not just stop it starting again later.
+ h.settings = { enabled: false, intervalSeconds: 30 };
+ await h.scheduler.tick(2000);
+ expect(h.calls.length).toBe(1);
+ });
+
+ it('skips this tick only when a settings read fails, rather than deciding from defaults', async () => {
+ const h = harness([VIEW('a')]);
+ h.settings = null as unknown as GitFetchSettings;
+ const logged = vi.fn();
+ h.ports.log = logged;
+ expect(await h.scheduler.tick(0)).toBe(0);
+ expect(h.calls).toEqual([]);
+ expect(logged).toHaveBeenCalled();
+
+ h.settings = SETTINGS;
+ // Nothing was recorded for the failed tick, so the folder is still due at once.
+ expect(await h.scheduler.tick(1000)).toBe(1);
+ });
+
+ /**
+ * The tick runs every 15 s whether or not the feature is on, so an unreadable settings.json would be logged
+ * every 15 s, and V8's parse message quotes the text around the fault: a hand edit with single quotes put
+ * the API key it held into the log on every tick.
+ */
+ it('logs an unreadable settings.json once until it reads again, and never with the text of the file', async () => {
+ const secret = 'sk-leak42';
+ const broken = `{"voiceSettings":{"apiKey":'${secret}'}}`;
+ await mkdir(dirname(SETTINGS_PATH), { recursive: true });
+ await writeFile(SETTINGS_PATH, broken);
+ try {
+ // The real readers: what they reject with is what gets logged.
+ await expect(readGitFetchSettings()).rejects.toThrow(/not valid JSON/);
+ await expect(readGitFetchSettings()).rejects.not.toThrow(secret);
+ await expect(readKeepWarmSettingsOrThrow()).rejects.not.toThrow(secret);
+
+ const h = harness([VIEW('a')]);
+ h.ports.readSettings = readGitFetchSettings;
+ const logged = vi.fn();
+ h.ports.log = logged;
+ for (const now of [0, 15_000, 30_000]) expect(await h.scheduler.tick(now)).toBe(0);
+ expect(h.calls).toEqual([]);
+ expect(logged).toHaveBeenCalledTimes(1);
+
+ // A read that succeeds in between re-arms the warning for the next failure.
+ await writeFile(SETTINGS_PATH, JSON.stringify({ gitFetchEnabled: true }));
+ expect(await h.scheduler.tick(45_000)).toBe(1);
+ await writeFile(SETTINGS_PATH, broken);
+ expect(await h.scheduler.tick(400_000)).toBe(0);
+ expect(await h.scheduler.tick(415_000)).toBe(0);
+ expect(logged).toHaveBeenCalledTimes(2);
+ expect(JSON.stringify(logged.mock.calls)).not.toContain(secret);
+ } finally {
+ await rm(SETTINGS_PATH, { force: true });
+ }
+ });
+});
+
+describe('the bounded probe', () => {
+ it('probes the folder before fetching it, and skips anything that is not a directory', async () => {
+ const kinds = new Map([
+ ['/w/file', 'file'],
+ ['/w/unknown', 'unknown'],
+ ['/w/absent', 'absent'],
+ ]);
+ const h = harness([VIEW('file'), VIEW('unknown'), VIEW('absent'), VIEW('ok')], {
+ probe: async (path) => kinds.get(path) ?? 'directory',
+ });
+ const logged = vi.fn();
+ h.ports.log = logged;
+ expect(await h.scheduler.tick(0)).toBe(1);
+ expect(h.calls.map((c) => c.cwd)).toEqual(['/w/ok']);
+ // The folders that answered badly were probed and left alone, never fetched.
+ expect(h.probed).toEqual(['/w/file', '/w/unknown', '/w/absent', '/w/ok']);
+ expect(logged).toHaveBeenCalledTimes(3);
+ // `unknown` is never read as `absent`: the log says what it answered rather than what it was assumed.
+ expect(logged.mock.calls.join(' ')).toContain('unknown');
+ });
+});
+
+describe('who runs', () => {
+ it('runs every session the app setting covers, once per interval', async () => {
+ const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } });
+ await h.scheduler.tick(0);
+ expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']);
+
+ await h.scheduler.tick(30_000); // not due yet
+ expect(h.calls.length).toBe(2);
+
+ await h.scheduler.tick(60_000);
+ expect(h.calls.length).toBe(4);
+ });
+
+ it('runs a session that turned itself on even while the app-wide switch is off: that is what a per-session switch has to mean', async () => {
+ const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], {
+ settings: { enabled: false, intervalSeconds: 300 },
+ });
+ await h.scheduler.tick(0);
+ expect(h.calls.map((c) => c.cwd)).toEqual(['/w/own']);
+
+ // Its own interval, not the app's: due at 30 s, not at 300.
+ await h.scheduler.tick(30_000);
+ expect(h.calls.length).toBe(2);
+ });
+
+ it('never fetches a remote or Docker session, exactly as the Git panel excludes them', async () => {
+ const h = harness([
+ VIEW('ssh', { remote: { host: 'x' } }),
+ VIEW('docker', { docker: { container: 'c' } }),
+ VIEW('local'),
+ ]);
+ await h.scheduler.tick(0);
+ expect(h.calls.map((c) => c.cwd)).toEqual(['/w/local']);
+ });
+
+ it('re-checks the Docker rule every tick, so a session whose case became a Docker case stops fetching', async () => {
+ const sessions = [VIEW('a')];
+ const h = harness(sessions);
+ await h.scheduler.tick(0);
+ expect(h.calls.length).toBe(1);
+
+ sessions[0] = { ...sessions[0], docker: { container: 'c' } };
+ await h.scheduler.tick(400_000);
+ expect(h.calls.length).toBe(1);
+ });
+
+ /**
+ * The Docker case list is rewritten in place, so a read can land on a half-written file. Read as "no Docker
+ * workspaces", a tick would run git (and the repository's hooks) inside a folder a container can write to.
+ */
+ it('fetches nothing when the Docker case list cannot be read, keeps the folders due, and logs it once', async () => {
+ const h = harness([VIEW('a'), VIEW('b')]);
+ let unreadable = true;
+ h.ports.dockerWorkspaces = async () => {
+ h.dockerReads++;
+ if (unreadable) throw new Error('docker-cases.json is not a valid JSON array');
+ return [];
+ };
+ const logged = vi.fn();
+ h.ports.log = logged;
+ expect(await h.scheduler.tick(0)).toBe(0);
+ expect(await h.scheduler.tick(15_000)).toBe(0);
+ expect(h.calls).toEqual([]);
+ expect(h.dockerReads).toBe(2); // the picks were undone, so the next tick picked them again
+ expect(logged).toHaveBeenCalledTimes(1);
+ expect(h.scheduler.tracked()).toBe(0); // nothing left in flight or stamped
+
+ // The list reads again: both folders run at once, not an interval after the failed ticks.
+ unreadable = false;
+ expect(await h.scheduler.tick(30_000)).toBe(2);
+ expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']);
+ // And their interval is timed from that run.
+ expect(await h.scheduler.tick(30_000 + 299_000)).toBe(0);
+ expect(await h.scheduler.tick(30_000 + 300_000)).toBe(2);
+ });
+
+ it('is wired to the strict Docker reader the Fetch button uses', () => {
+ const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
+ const block = server.slice(server.indexOf('new GitFetchScheduler('));
+ // defaultDockerWorkspaces is the strict one: test/routes/git-status-routes.test.ts pins that it rejects.
+ expect(block.slice(0, block.indexOf('});'))).toContain('dockerWorkspaces: defaultDockerWorkspaces,');
+ });
+
+ it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => {
+ const h = harness([VIEW('a')]);
+ await h.scheduler.tick(0);
+ expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
+ });
+});
+
+describe('one folder, one run', () => {
+ it('fetches a shared folder once and publishes the result to every session open on it', async () => {
+ const h = harness([VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]);
+ expect(await h.scheduler.tick(0)).toBe(1); // one folder, not two runs
+ expect(h.calls.length).toBe(1);
+ expect(h.published.map((p) => p.id).sort()).toEqual(['a', 'b']);
+ // Both see the same run, so the panel's row says the same thing in both tabs.
+ expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1);
+ });
+
+ it('is due when ANY session open on the folder is due, in either read order, because the clock belongs to the folder and not to whichever session was read first', async () => {
+ const short = { ...VIEW('short'), workingDir: '/w/shared', gitFetchIntervalSeconds: 30 };
+ const long = { ...VIEW('long'), workingDir: '/w/shared', gitFetchIntervalSeconds: 300 };
+
+ for (const order of [
+ [short, long],
+ [long, short],
+ ]) {
+ const h = harness(order);
+
+ // Nothing has run yet, so both are due and both are published from the one run. The folder is probed
+ // once, not once per session open on it.
+ expect(await h.scheduler.tick(0)).toBe(1);
+ expect(h.probed).toEqual(['/w/shared']);
+ expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
+
+ // Before the short interval elapses neither is due, and the order they were read in cannot change that.
+ h.published.length = 0;
+ expect(await h.scheduler.tick(15_000)).toBe(0);
+ expect(h.published).toEqual([]);
+
+ // The short session is past its own interval, so the folder is fetched (once, for both). The run that
+ // happens covers the long session's interval too, and both still get it because both are open on the
+ // folder: publishing only the session that made the folder due would leave the other tab on a stale row.
+ h.published.length = 0;
+ expect(await h.scheduler.tick(30_000)).toBe(1);
+ expect(h.calls.length).toBe(2);
+ expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
+
+ // The long one's own interval comes round, so the folder is due again.
+ h.published.length = 0;
+ expect(await h.scheduler.tick(300_000)).toBe(1);
+ expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
+ }
+ });
+
+ it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => {
+ const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`);
+ const gate = deferred(run('/w/first', 'first'));
+ const h = harness(
+ ids.map((id) => VIEW(id)),
+ {
+ settings: { enabled: true, intervalSeconds: 30 },
+ fetch: () => gate.promise,
+ }
+ );
+
+ const first = h.scheduler.tick(0);
+ await new Promise((resolve) => setImmediate(resolve)); // the first runs are going, the rest are queued
+ const picked = h.calls.length;
+ expect(picked).toBe(FETCH_CONCURRENCY);
+
+ // The server fires the next tick without waiting. The schedule was stamped at selection, so the folders
+ // already picked are not picked again, and this tick is skipped while the previous one is still going.
+ expect(await h.scheduler.tick(15_000)).toBe(0);
+ expect(h.calls.length).toBe(picked);
+
+ gate.resolve(run('/w/first', 'first'));
+ await first;
+ // Every folder ran exactly once, and no folder ran twice.
+ expect(h.calls.length).toBe(ids.length);
+ expect(new Set(h.calls.map((c) => c.cwd)).size).toBe(ids.length);
+ });
+
+ it('shares one bound across the whole tick instead of giving each folder its own, and bounds the folders with a bound of its own', async () => {
+ const ids = Array.from({ length: FETCH_CONCURRENCY + 1 }, (_, i) => `s${i}`);
+ let fetchesLive = 0;
+ let fetchesPeak = 0;
+ let foldersLive = 0;
+ let foldersPeak = 0;
+ const hold = () => new Promise((resolve) => setImmediate(resolve));
+ const h = harness(
+ ids.map((id) => VIEW(id)),
+ {
+ // Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a
+ // per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a
+ // DIFFERENT bound, because one semaphore cannot serve both levels: a folder holding a slot and then
+ // waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are
+ // exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail
+ // as a timeout rather than as an assertion.
+ fetch: async (cwd, opts) => {
+ foldersLive++;
+ foldersPeak = Math.max(foldersPeak, foldersLive);
+ await opts.limiter?.([0, 1, 2, 3, 4, 5], async () => {
+ fetchesLive++;
+ fetchesPeak = Math.max(fetchesPeak, fetchesLive);
+ await hold();
+ fetchesLive--;
+ });
+ foldersLive--;
+ return run(cwd, cwd.slice(3));
+ },
+ }
+ );
+ await h.scheduler.tick(0);
+ expect(fetchesPeak).toBe(FETCH_CONCURRENCY); // the tick's ceiling, not each folder's own
+ expect(foldersPeak).toBe(FETCH_CONCURRENCY);
+ expect(h.calls.length).toBe(ids.length); // every folder ran, none was left waiting forever
+ });
+
+ it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => {
+ const slow = deferred(run('/w/a', 'a'));
+ const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise });
+
+ const first = h.scheduler.tick(0);
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(h.calls.length).toBe(1);
+
+ // The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the
+ // next tick runs it again instead of waiting another 30 s from when the slow one finished.
+ slow.resolve(run('/w/a', 'a'));
+ await first;
+ expect(await h.scheduler.tick(200_000)).toBe(1);
+ expect(h.calls.length).toBe(2);
+ });
+});
+
+describe('what a run reports', () => {
+ it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => {
+ const h = harness([VIEW('a')]);
+ await h.scheduler.tick(0);
+ expect(h.published).toEqual([
+ {
+ id: 'a',
+ status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 },
+ },
+ ]);
+ });
+
+ it('publishes the trigger of the run that actually happened rather than the trigger the tick asked for, because a tick that joined a click did not fetch anything', async () => {
+ const h = harness([VIEW('a')], {
+ // The port is `fetchWorkspaceOnce`, so it can hand back a run a click had already started for this
+ // folder. The tick asked for its own; the row has to name the run.
+ fetch: (cwd, _opts, trigger) => {
+ expect(trigger).toBe('interval');
+ return Promise.resolve({ report: report(cwd, 'a'), trigger: 'click' });
+ },
+ });
+ await h.scheduler.tick(0);
+ expect(h.published[0].status.trigger).toBe('click');
+ });
+
+ it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => {
+ const h = harness([VIEW('a')], {
+ settings: { enabled: true, intervalSeconds: 30 },
+ fetch: () => Promise.reject(new Error('spawn git ENOENT')),
+ });
+ const logged = vi.fn();
+ h.ports.log = logged;
+ await h.scheduler.tick(0);
+ expect(logged).toHaveBeenCalled();
+ expect(h.published).toEqual([]);
+ // Still remembered, so the next run is on schedule rather than immediate.
+ expect(h.scheduler.tracked()).toBe(1);
+ });
+});
+
+describe('the effective choice', () => {
+ it("a session's own override wins over the app setting", () => {
+ const settings = { enabled: false, intervalSeconds: 600 };
+ expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({
+ enabled: true,
+ intervalSeconds: 45,
+ });
+ // Half a choice still follows the app for the other half.
+ expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({
+ enabled: true,
+ intervalSeconds: 600,
+ });
+ });
+
+ it('re-clamps a persisted interval that can predate the schema bounds', () => {
+ expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({
+ enabled: true,
+ intervalSeconds: 30,
+ });
+ expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({
+ enabled: true,
+ intervalSeconds: 3600,
+ });
+ });
+});
+
+describe('a session that goes away', () => {
+ it('takes its own choice with it, but keeps the folder while another session is still open on it', async () => {
+ const sessions = [VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }];
+ const h = harness(sessions);
+ await h.scheduler.tick(0);
+ expect(h.scheduler.tracked()).toBe(1);
+
+ h.remove('a');
+ // The run belongs to the clone, so 'b' keeps the folder's schedule and its next run is still on time:
+ // the choice that got the folder going was 'a's, but the folder is not 'a's.
+ expect(h.scheduler.tracked()).toBe(1);
+ await h.scheduler.tick(1000); // not due yet, and 'b' is still open on the folder
+ expect(h.calls.length).toBe(1);
+
+ // The last session open on the folder goes, so the folder is forgotten: a session coming back on it is due
+ // at once rather than on the schedule the closed one left behind.
+ h.remove('b');
+ expect(h.scheduler.tracked()).toBe(0);
+ sessions.push({ ...VIEW('c'), workingDir: '/w/a' });
+ await h.scheduler.tick(2000);
+ expect(h.calls.length).toBe(2); // remembered again, from now
+ });
+
+ it('stops everything after stop(), including a folder that was already due', async () => {
+ const h = harness([VIEW('a')]);
+ h.scheduler.stop();
+ expect(await h.scheduler.tick(0)).toBe(0);
+ expect(h.calls).toEqual([]);
+ expect(h.scheduler.tracked()).toBe(0);
+ });
+});
diff --git a/test/git-fetch-session-options-ui.test.ts b/test/git-fetch-session-options-ui.test.ts
new file mode 100644
index 000000000..ec2ec44aa
--- /dev/null
+++ b/test/git-fetch-session-options-ui.test.ts
@@ -0,0 +1,155 @@
+/**
+ * @fileoverview The Session Options → Git rows (session-ui.js `updateGitFetchStatus`) as the page drives them: the
+ * real index.html in JSDOM with the real app.js and session-ui.js loaded into it, and every session update
+ * delivered through app.js `_onSessionUpdated`, the path a `session:updated` SSE event takes.
+ *
+ * 1. A working session sends `session:updated` on each working/idle change, and that path refreshes the open Git
+ * tab every time, so the control the user is in is left alone: rewriting it put a half-typed "12" back to "300"
+ * before `onchange` could save it. Enter saves with the field still focused, so the save writes the clamped
+ * number back itself.
+ * 2. The route takes one field at a time, so a session can hold half an override. The half it did not choose
+ * shows the app's value, which is what the scheduler runs it with, never a default.
+ *
+ * Port: N/A (no server; the one save is answered by a stub).
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const SID = 'session-git-rows';
+/** The app setting every case here runs against: on, every 900 s. */
+const APP = { gitFetchEnabled: true, gitFetchIntervalSeconds: 900 };
+
+type Update = Record & { id: string };
+type TestApp = Record & {
+ _onSessionUpdated(data: Update): void;
+ autoSaveGitFetch(): Promise