diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index e5283924..4fceaf6e 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.33.2",
+ "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.48.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 2ed1d8f1..02874dd1 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -55,7 +55,9 @@ npm run test:all # literally everything, environmental failures included
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
-If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
+The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
+
+If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml
new file mode 100644
index 00000000..caadba59
--- /dev/null
+++ b/.github/workflows/browser-suite.yml
@@ -0,0 +1,50 @@
+name: Browser suite
+
+# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
+# so a browser-only regression can merge green. This job runs that suite on a schedule and on
+# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
+# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
+# push or a PR.
+#
+# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
+# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
+# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
+
+on:
+ schedule:
+ - cron: '29 3 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ browser:
+ name: Playwright browser suite
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install tmux
+ run: |
+ if ! command -v tmux >/dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y tmux
+ fi
+
+ - name: Install chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Run the browser suite
+ run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe86f671..4df79374 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -219,6 +219,8 @@ jobs:
run: npx vitest run
working-directory: packages/xterm-zerolag-input
+# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
+# that job is informational and never gates a push or a PR.
# Note: three suites are excluded from CI, each with its own local runner:
# npm run test:browser Playwright + chromium (+ a live server, and a real
# codex binary for codex-predictive-echo)
diff --git a/.gitignore b/.gitignore
index 5113ac0a..ff7d124e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,8 +8,9 @@ DECISIONS.md
# Written by install.sh into end-user clones when setup finishes
.install-complete
-# Dependencies
-node_modules/
+# Dependencies. No trailing slash: `node_modules/` matches only a directory, so a
+# worktree's `node_modules` symlink to a shared install got committed (#658, #662).
+node_modules
# Build output
dist/
@@ -84,6 +85,23 @@ tmp/
# added there). No trailing slash so it still matches the symlink, not just dirs.
/public
+# Video never goes to GitHub (docs/media-assets.md). The only tracked moving
+# images are the GIFs a public page embeds, in docs/images/; masters, promo cuts,
+# drafts and captures live in the local media library pr/media/ (ignored above).
+# The promo branch tracks its Remotion render inputs, so those stay addable.
+# A deliberate exception elsewhere takes `git add -f`.
+*.mp4
+*.webm
+*.mov
+*.m4v
+*.mkv
+*.avi
+*.gif
+!/docs/images/*.gif
+!/scripts/remotion/public/**/*.mp4
+!/scripts/remotion/public/**/*.webm
+!/scripts/remotion/public/**/*.gif
+
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
# (The gesture bundle itself, gesture-codeman.js, IS tracked — built from
@@ -107,7 +125,9 @@ todo.md
@fix_plan.md
readme-preview.mjs
-# Uploaded images land here under each session working dir (runtime artifact)
+# Prompt uploads land here under each session working dir (runtime artifact);
+# .claude-images/ is where they landed before the move.
+.codeman-uploads/
.claude-images/
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
diff --git a/.prettierignore b/.prettierignore
index cdfaf71e..c4427d1c 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -24,8 +24,9 @@ src/web/public/settings-ui.js
src/web/public/sw.js
src/web/public/terminal-ui.js
src/web/public/voice-input.js
-src/web/public/upload.html
scripts/remotion/
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
CLAUDE.md
+src/web/CLAUDE.md
+docs/releasing.md
diff --git a/AGENTS.md b/AGENTS.md
index 49991d86..a3b084da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/.test.ts` for one file
-- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
+- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53aeb9c8..a41256ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,463 @@
# aicodeman
+## 1.48.0
+
+### Minor Changes
+
+- 4960bee: ### Thanks
+ - @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
+ - @Randalix for the Edit history panel (#693).
+ - @aakhter for relative links that follow your `cd` (#740).
+ - @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
+ - @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
+ - @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
+
+ **Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "" [--urgent] [--file ]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
+
+ **Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
+
+ **Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
+
+ **Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
+
+ **Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
+
+ **Fixes.**
+ - **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
+ - **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
+ - **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
+ - **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
+ - **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
+ - **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
+
+ **Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
+
+ **Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
+
+## 1.47.0
+
+### Minor Changes
+
+- 1ab01bb: ### Thanks
+ - @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729).
+ - @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname.
+ - @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716.
+ - @opticon454 for rendered Markdown that fills a resized File Viewer (#730).
+ - @JDProfresh for a middle click on a file path that opens the file (#731).
+
+ **The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap.
+
+ **Fixes.**
+ - **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before.
+ - **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane.
+ - **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder.
+ - **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it.
+ - **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click.
+ - **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice.
+
+ **Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726).
+
+ **Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module.
+
+## 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
+
+- ### Thanks
+ - @opticon454 for five PRs: renaming a case and its folder with the live sessions handled (#680), a resizable line between the tabs and the header toolbar (#689), opt-in GPU and CPU clock readings in the header (#692), and two Codex wheel fixes measured against the real CLI, a first notch that moves the transcript (#699, from #697) and a wheel that no longer moves a selection menu's choice (#698, from #696).
+ - @aakhter for five PRs: an agent picker on the Cmd+K New session row (#685), a login throttle that no longer locks a whole tunnel out after ten typos (#684), an SSE connect attempt that can no longer hang forever (#678), prompts that start with a hyphen (#677), and `req` made required on the session lookup helpers so an ownership check cannot be skipped by accident (#683).
+ - @JDProfresh for a phone swipe that follows the order the strip draws (#682) and an inline Claude tab whose history loads instead of paging the composer after a server restart (#681), both found and tested on an iPhone.
+ - @Randalix for the per-session agent inbox (#690), the shape agreed in Discussion #558.
+ - @Hugo-DDT for closing the last two ways a key binding could move a client onto another session (#688), and @christianhaberl for reporting both in #671.
+ - @johoja12 for remote hosts whose tmux lives in Homebrew or a per-user bin directory (#691), the follow-up promised in #645.
+
+ **Rename a case and its folder (#680).** The case manager has a Rename action. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history (including the Resume list), the case order, the last used case and the default session group follow the new name.
+
+ **A per-session agent inbox (#690).** Agents can leave each other messages without typing into anyone's pane: `codeman agent post ""`, `codeman agent inbox [--wait]` and `codeman agent ack `, over `POST`/`GET /api/sessions/:id/inbox`, `POST .../inbox/ack` and `GET /api/agent-inbox/summary`. A message stays until its receiver acknowledges it, so nothing is lost between a read and the ack, and `codeman agent ls` shows an INBOX column. Inboxes live in memory by default; **Keep agent inbox across restarts** (Settings, Cross-session features, off by default) writes them to a 0600 file in the data dir, kept for 12 hours.
+
+ **Pick the agent from Cmd+K (#685).** The palette's New session row shows the agent it will start, with arrows to change it (Left/Right while the row is highlighted). It starts on the Run button's mode, offers the same agents the Run menu offers for that case (a Docker case's container agents included), and launches once without changing the Run button.
+
+ **A resizable header toolbar (#689).** Drag the line between the session tabs and the header toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it, the arrow keys move it, and the width is remembered on the device.
+
+ **GPU and CPU clock readings (#692, opt-in).** **GPU Stats** adds GPU load, graphics memory and the GPU clock after CPU and MEM (each only when the host reports it), and **CPU MHz** adds the live CPU clock. Both are in Settings, Header & Panels, off by default, and nothing is read unless the browser asks. NVIDIA is read through `nvidia-smi`, AMD through sysfs, and inside WSL any vendor through the Windows performance counters (no GPU clock there). The header also keeps polling its stats with no session open, so CPU and MEM no longer sit at `--`.
+
+ **Fixes.**
+ - **A tunnel no longer locks everyone out after ten typos (#684, multi-user mode).** Behind `tailscale serve` or a tunnel every login arrives from one address, so ten wrong passwords from anyone used to lock every user out for 15 minutes. The per-address limit is now 50, and each account allows ten failed guesses per address, both checked before the password is verified. Another address can no longer lock an account out, a burst of parallel guesses counts in full, and identical logins in flight share one check, so a reload with many tabs open is never refused. Single-user mode is unchanged.
+ - **The first wheel notch moves a Codex transcript (#699).** When a CLI does not track the mouse, Codeman turns the wheel into PageUp/PageDown, and it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 in Firefox. The first notch now pages at once and the notches after it repay it, so the page rate is unchanged.
+ - **The wheel no longer moves a Codex menu's selection (#698).** At "Implement this plan?" one notch down used to land on "No, stay in Plan mode" and one up on "Yes, implement this plan", because those menus use the page keys. The wheel is now ignored while such a menu shows, in the main pane and in tiles, and pages the transcript again once it closes.
+ - **History loads after a restart instead of paging Claude (#681).** After a server restart, a second visit to an inline Claude tab on a phone turned every swipe into PageUp/PageDown for Claude, so the composer jumped around and old output never showed. The swipe now loads tmux's history, as the first visit does.
+ - **A phone swipe follows the strip (#682).** With the case or state layout, a sorted rail or named groups, swiping now moves to the session drawn next to the current one, not the next one in the hidden canonical order. Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, matching the Alt+N badges.
+ - **Key bindings can no longer move a client onto another session (#688, fixes #671).** The tmux servers behind Docker cases and owned remote sessions keep only their send-prefix binding, as Codeman's own socket has since 1.44.0, and `codeman tui`'s Alt+1..9 switch only the TUI's own client: an Esc then a digit in a browser tab reaches its pane instead of switching it.
+ - **Remote tmux found in Homebrew or a per-user bin (#691).** Every remote tmux call (availability check, launch, attach, discovery, capture, cleanup) now runs on one widened, quoted PATH, so a host whose tmux works only in an interactive shell links and launches, home directories with spaces included.
+ - **Prompts that start with a hyphen (#677).** Text typed into a pane that began with `-` (`-x hello`, `--help`) was read by tmux as its own options and never arrived.
+ - **A connect attempt that never opens is retried (#678).** After a phone resumes on a VPN or tailnet, an event stream could sit in "connecting" forever with no error to trigger a retry. Each attempt now gets 15 seconds before it is closed and retried with the usual backoff.
+
+ **Fixes applied while landing.** A case rename that fails half way now puts `settings.json` back too and says where the folder is; it merges Claude Code's history into a folder a deleted case of the new name left behind instead of silently leaving it at the old name, refuses a dangling symlink at the new name before any session is stopped, refuses to move a folder while a session is still being created in it, and the dialog says plainly that stopping closes the sessions (#680). The inbox keeps the mail of sessions on the reboot-restore offer and of a detached session that comes back, prints message text with control characters escaped, and flushes after the server stops accepting posts (#690). Cmd+K's agent list follows the case's own availability rule, so a Docker case can start a CLI only its container has, and with a single choice the row keeps the Run button's mode (#685). The divider's stored width can no longer go negative and Escape no longer resets it (#689). With the toolbar narrowed, the GPU, VRAM and clock readings keep their labels, so they can still be told apart from CPU and MEM, and at Compact's rings-only step a clock with nothing left to show is hidden instead of leaving a gap (#689, #692). The stats route no longer waits on Windows' RAM probe, and a hung `nvidia-smi` can no longer freeze the header's CPU and MEM readings: the `nvidia-smi` and Windows RAM probes now give up after 1.5 s (#692). A second `codeman tui` on a longer pts number can no longer remove the first one's guard, and a client that fails the guard gets its key instead of losing it (#688). A test that had started passing vacuously after the SSE change checks the connect URL again (#678).
+
+ **Docs and tests.** `req` is now required on `findSessionOrFail` and `findPersistedSessionOrFail`, pinned by a test that also checks its exact type (#683). New sections for the agent inbox, the case rename, the header divider and the host sensors, the SSE connect timer next to the staleness watchdog, the login buckets in the security docs, and the remote PATH rule in the architecture notes.
+
+## 1.44.0
+
+### Minor Changes
+
+- ### Thanks
+ - @opticon454 for five PRs: config backups you can compare and restore (#661, ten review rounds), a File Viewer you can resize, expand and walk Back and Forward through (#659), GitHub Copilot's premium-request quota in the plan-usage chip (#662), a warning when llama.cpp's parallel slots leave too little context per request (#658, from #641), and a bare `npx vitest` that can no longer write into your real `~/.codeman` (#660, from #640).
+ - @JDProfresh for four PRs: the opt-in prompt-cache keep-alive (#666, step 2 of #554), Markdown links to file paths that open in the File Viewer (#665), a phone keyboard that stays up while you press accessory keys (#673), and a session swipe that no longer taps or scrolls the session you are leaving (#674), with the iPhone testing behind both phone fixes.
+ - @christianhaberl for four PRs: `CODEMAN_MAX_SESSIONS` (#668), tmux prefix keys that can no longer move a browser tab onto another session (#669), multi-line prompts that keep their line breaks (#670), and rediscovered tmux sessions that keep their own folder (#667). Also for the earlier #224, #225, #226 and the session sidebar (#307), whose commits carried an agent identity that GitHub credits to another account.
+ - @johoja12, a first-time contributor, for scroll batches that stay with the tab that made them, smoother trackpad scrolling and wheel scrolling in fullscreen Codex (#645), measured against the real CLI through five review rounds.
+ - @ludovicfourrage for touch scrolling in apps that track the mouse themselves (#664), and the careful write-up in #663.
+ - @aakhter for a default tab group per case (#676).
+
+ **Config backups (#661).** Codeman now keeps timestamped copies of the files that hold your setup: settings, CLI overrides, custom model endpoints, the webhook, linked cases and, in multi-user mode, the accounts. It takes one at startup and whenever one of them changes, into `~/.codeman/backups/config` by default. App Settings → System lets you pick the folder and the retention (keep N backups, drop ones older than N days), back up now, compare two backups or a backup with what is running (credentials are never shown, only whether they changed), and restore one. A restore first saves what it is about to replace, so it can be undone, and retention only ever deletes folders Codeman made and never the newest backup.
+
+ **Keep an idle Claude session's prompt cache warm (#666, opt-in).** Claude Code's prompt cache expires after an hour of quiet, and the next prompt then pays to re-read the whole conversation. Switch on **Keep the cache warm while idle** in a session's Respawn tab (or **Keep prompt caches warm** in App Settings → System → Automation for every new session) and Codeman sends a one-line keep-alive turn about five minutes before the cache would expire. It only does that when nothing else is going on: an empty prompt, no open dialog, nobody typing, no respawn, Ralph loop or waiting agent, no usage-limit pause. It stops on its own a while after your last prompt (**Keep warm for at most**, default 2 hours). A session started with it on also launches with Claude Code's own idle compaction off (Claude Code 2.1.290 or newer). The keep-alive raises no alert, push, webhook or Away-digest row, and the readout's Status line says what it is doing.
+
+ **A default tab group per case (#676).** Case settings has a **Default session group**: every new session for that case lands straight in that tab group, in the same write that creates it, so it never flashes in Ungrouped first. An explicit group on the create request and a spawned child following its parent still win. It is stored per user and survives a group rename.
+
+ **A File Viewer you can resize, with Back and Forward (#659).** Drag the corner grip to resize the window, or press Expand to fill the screen (remembered on this device). Following a link from one file to another builds a history that the ← and → buttons and Alt+← / Alt+→ walk through. Phones keep the full-screen viewer.
+
+ **Copilot's premium-request quota in the plan-usage chip (#662).** With the GitHub Copilot CLI installed and signed in, the header chip gains a Copilot row for the month's premium requests, with the counts and the reset date in the tooltip. Codeman reads it every 10 minutes from GitHub with the Copilot CLI's own sign-in token. It is on by default, like the Codex row, and turning Plan Usage Limits off in App Settings stops the request.
+
+ **More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668).** The cap of 50 concurrent sessions can be set at startup, from 1 to 462. A value that is not a positive integer falls back to 50, and the per-user cap in multi-user mode follows it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set.
+
+ **Multi-line prompts keep their line breaks (#670).** A prompt sent through the API with `useMux` used to arrive as one welded line (`line oneline two`). Its lines now go into the pane as one bracketed paste, followed by the Enter that submits them together. The senders that run unattended (cron, respawn's update and kickstart prompts, the auto-compact focus, `codeman agent send`, the TUI and the skill's `sendwait`) stay single-line and join lines with a space, because the submit check cannot yet recognise a multi-line paste in the composer.
+
+ **A warning for llama.cpp slots that split the context too thin (#658).** A custom llama.cpp model that runs several parallel slots splits its context between them, and four 40K-token Claude requests against a 4-slot, 131K pool all failed with "Context size has been exceeded". Discovery now records each model's slot count, and a Claude launch warns when one slot gets less than 40K tokens, naming the fix (`--parallel 1`, or a context large enough for every slot).
+
+ **Fixes.**
+ - **tmux prefix keys stay out of browser tabs (#669).** Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to another session while the tab kept the first one's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, set at session start and at boot. This also means `Ctrl+B d` no longer detaches a terminal attached to that socket: `codeman tui` and `scripts/tmux-manager.sh` detach with F1.
+ - **Scrolling stays with its tab, and fullscreen Codex scrolls (#645).** A queued wheel batch can no longer land in a tab you switched to, trackpads keep their fractional travel instead of dropping it, the first wheel report of a tab visit goes out at once, and Codex gets wheel reports while it has mouse tracking on (measured on Codex 0.163.0, where an approval prompt's selection is unaffected).
+ - **Touch scrolling in apps that track the mouse (#664).** A finger drag and its fling now scroll a program in the pane that asked for SGR mouse reports (a TUI in a shell tab, for example) instead of scrolling a local buffer that cannot show it. Other mouse encodings are left alone.
+ - **A session swipe only switches sessions (#674).** A sideways swipe on the terminal no longer also opens a link, taps a CLI menu or toggles the keyboard on the session you are leaving. A deliberate swipe gets 500 ms instead of 300, a selection or a second finger pauses it, and a scroll that starts on a sideways thumb arc still scrolls.
+ - **The phone keyboard stays up (#673).** Arrow, Tab, Esc and the other accessory keys that act on the terminal no longer blur it for a moment, so the toolbar stops bouncing on every tap.
+ - **Markdown links to files open in the File Viewer (#665).** A link like Codex's `[label](/abs/path.json)` in the response viewer now opens the file instead of a same-origin 404 tab, and its label keeps the prose font.
+ - **Rediscovered sessions keep their folder (#667).** A tmux session Codeman finds again on its socket (after `mux-sessions.json` was lost) now gets the directory tmux reports for its pane instead of the server's own, so a respawn goes back to the right project.
+ - **Web tabs send POST bodies to dashboards that read Content-Length.** Codeman's web-tab proxy now relays the browser's Content-Length, so a dashboard built on Python's `http.server` or a small Go or C server no longer sees an empty body on every POST made through a web tab.
+
+ **Fixes applied while landing.** A restore that fails partway now says which files it restored, which it could not and where the pre-restore copy is, re-reads what was written and refills the Settings form; backup passes list the manifests once, and a pre-restore copy equal to the newest backup is not made twice (#661). A failed keep-alive write is retried before the expiry instead of never, an unreadable `settings.json` is logged once instead of every minute for every session, the keep-alive's own hooks stay out of the run summary, and saving App Settings re-plans keep-alives only when their settings changed (#666). The Copilot quota read checks the setting and the sign-in token before it ever probes for the CLI, so a machine without Copilot runs no login shell every 10 minutes, and drops a header the Copilot CLI itself does not send (#662). The prefix-table reset still binds send-prefix on an empty table and no longer reruns on every session for a prefix tmux lists escaped (#669). Claude worktree sessions follow the case's default group like every other CLI's (#676). The remote-history PATH change that rode along in #645 was left for its own PR. Smaller touches: the Expand button keeps its label while pressed and Alt+← / Alt+→ are listed in the shortcut overlay and help (#659); the scroll log names xterm's mouse encoding (#664); the response-viewer link test covers `?query` and folder links (#665).
+
+ **Docs and tests.** A bare `npx vitest run` from the repo now uses the isolated CI config instead of running without the setup file, the helpers that delete test folders refuse to run outside a sandboxed HOME, and the temp-HOME teardown is bounded so a stuck writer cannot time the hook out (#660). New sections and lines for config backups, the keep-alive and the Copilot quota in the architecture notes and `CLAUDE.md`, a test that a deleted pane directory is never adopted (#667), the codeman skill preamble re-stamped 1.44.0, and browser tests for the touch-scroll and swipe fixes.
+
+## 1.43.0
+
+### Minor Changes
+
+- ### Thanks
+ - @opticon454 for five PRs: GitHub Copilot CLI as a run mode (#592), an Antigravity tab that shows when it is working (#648, reported in #639), a WS pill that no longer flickers on every paste (#647, reported in #638), custom-folder cases anywhere below a broad cases folder (#567, reported in #637), and the opt-in "Pin tab names on the CLI" setting (#646). Also for filing #637 to #641 with the cause already worked out, and for measuring Copilot and `agy` live instead of guessing.
+ - @JDProfresh for four follow-ups to the document uploads in #621: a mixed pick, drop or paste that says how many files it skipped (#651), a text upload refused as "not UTF-8" instead of a type mismatch (#652), a parity test that keeps the browser's copies of the document list honest (#653), and link taps on a phone that land on the right row after a tab switch (#654).
+ - @irisitymichaelgrundberg for clickable `.diff` and `.patch` paths (#644).
+
+ **Security notices for the CLIs you run.** Codeman can now warn you about a security incident in an agent CLI it runs, and it does so only on machines where that CLI is installed. After an update the warning sits inside the "Codeman updated" note, with a button to the vendor's own notice next to **See what's new**; on a new install, or if you missed it, it comes as a card of its own. It stays until you close it or open the link, and expires on its own. The first one is about OpenCode: on 17 October OpenCode reported unauthorized access to a legacy system, and provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.
+
+ **GitHub Copilot CLI is a run mode (#592).** Install `copilot` (`npm install -g @github/copilot`) and it appears in the Run menu, on the welcome screen, in cron and in CLI management. It runs in tmux like the other agent CLIs, shows working and idle and the model it reads off its own footer, takes the usual launch options (`copilotConfig`: model, resume, continue, and `allowAll`, which is clamped off for non-granted multi-user owners), works with Custom Model Endpoints through Copilot's BYOK variables, joins MCP sync, and works with `codeman agent spawn`, `send` and `read`. A plain drag selects text, so Auto Copy works, while the wheel and phone swipes scroll its transcript. Copilot's folder-trust dialog still needs one Enter the first time a folder is used.
+
+ **Pin tab names on the CLI (#646, optional).** A new App Settings switch, off by default, launches every Claude session with `--name `, not only the ones you renamed, so Claude Code's prompt-box label, `/resume` entry and remote session list match your tabs. The cost, which the setting spells out: Claude stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. It applies from the next session start or respawn.
+
+ **Antigravity tabs show when they are working (#648).** An `agy` turn now marks its tab working and a finished turn reads as idle again. A shell command waiting for your approval keeps showing as working until you answer it.
+
+ **Fixes.**
+ - **Custom-folder cases below the cases folder (#567).** Create New with a custom folder refused every folder anywhere below the cases folder. Now only the cases folder itself and its direct children are refused, since those are the only ones listed as local cases, so a broad `CODEMAN_CASES_PATH` such as `/mnt/user/Scripts` no longer blocks the whole tree. The refusal now says which of the two it hit.
+ - **A steady WS pill (#647).** The ` · NB queued` suffix appears only once a backlog has lasted over a second, instead of flashing on every paste, IME commit or fast burst.
+ - **Clickable diff and patch files (#644).** A `.diff` or `.patch` path is now a link in the terminal and the response viewer, so an agent's review diff opens in one click.
+ - **Mixed uploads say what they skipped (#651).** A pick, drop or paste with some unsupported files attaches the rest and says how many it skipped; the Compose and paste-dialog pickers now report an unsupported pick instead of doing nothing.
+ - **"Not UTF-8" for refused text uploads (#652).** A text file that fails the UTF-8 check is told it is not UTF-8 (or UTF-16, when it starts with that byte order mark) and to save it as UTF-8, instead of reading like a corrupt file.
+ - **Link taps on a phone after a tab switch (#654).** With the view parked above the bottom, a URL typed into the prompt no longer opens on tap, and a link in the scrollback opens on its own row.
+
+ **Fixes applied while landing.** The Copilot run mode clamps every `COPILOT_*` variable that bypasses approval, runs code or moves the token for non-granted multi-user owners, keeps a divider row inside an answer as part of what `codeman agent read` returns, and its Docker docs no longer promise a token you cannot set there (#592). Antigravity's working line only matches at the start of a row, so an answer that quotes the spinner no longer holds the tab busy (#648). The custom-folder refusal names the cases folder itself and a direct child separately (#567). The WS pill reads its queue once per update (#647). The "Pin tab names" setting is translated for zh-CN and listed in the Settings Reference (#646). JSON files the page fetches at runtime (the update note, the security notices) are now served `no-cache` instead of cached for a year, so a later notice always reaches a browser that already has the old copy.
+
+ **Docs and tests.** New route tests for nested custom-folder cases and for Copilot's screen-read answers, a wrapped-composer touch test, stricter upload parity checks, and docs that move Antigravity and Copilot into the screen-checked idle group.
+
+## 1.42.0
+
+### Minor Changes
+
+- 1238316: ### Thanks
+ - @JDProfresh for five PRs: prompt uploads that take documents (#621, from the plan in #553), a video Codeman cannot take refused before it uploads (#622), a prompt-cache readout kept to the CLIs that report one and a statusline report with many cache-miss causes no longer thrown away (#623), and two fixes for Claude's workspace trust dialog in a brand-new folder: idle waits that hold until the dialog is answered (#634) and a dialog that gets answered every time (#635).
+ - @Randalix for Codex prompts that really get submitted, sent as one bracketed paste and a separate Enter (#628), and for the careful measurements in #627 that found the cause.
+ - @ludovicfourrage, a first-time contributor, for reporting (#629) and fixing (#632) the nested tmux status bar colour that leaked into live output after a history reload.
+ - @opticon454 for three PRs: a one-time "what's new" note after each update (#618), Previous and Next Session as rebindable shortcuts that refuse a chord another action already uses (#617), and a DeepSeek install from CLI management that ends with a DeepSeek you can run (#616).
+
+ **Git worktrees for a case (#625).** Give every agent on a repository its own checkout. Pick a case whose folder is a git repository, choose **Git worktrees…** in the case picker (**Worktrees** on the phone's case sheet), type a name and pick the CLI: Codeman creates `/.claude/worktrees/` on a new branch `worktree-` and opens a session there, in a tab named after the worktree. **Open** starts another session in a worktree you already have. Removing one runs plain `git worktree remove`, never `--force`, so uncommitted work stays where it is, and Close Session can kill the session and remove its worktree in one go. The layout is the one Claude Code's own `claude --worktree` uses, so each sees the other's worktrees. To keep the main checkout clean, Codeman lists the worktrees folder and the files it writes for sessions in the repository's local `.git/info/exclude`; your `.gitignore` is never touched.
+
+ **Codex sessions turn red when Codex asks you something (#624).** Codex's command and edit approvals, its question dialogs and the trust prompt on a new repository are read straight off the screen and raised the way a Claude permission prompt is: red tab, desktop notification, push, webhook, the phone's NEEDS YOU row and an Approvals Inbox card that tells you to answer in the terminal. An agent driving a codex worker can now wait for that with `until=blocked`.
+
+ **Prompt uploads take documents (#621).** Attach, drop or paste a PDF, a Word, PowerPoint or Excel file, a zip, or a text file (txt, md, csv, tsv, json, log, yaml, xml) and its path lands on your prompt, ready to send, like a photo or a video does. Every file is checked against its actual bytes on the way in, so a program renamed to `.pdf` is refused, and text files must be UTF-8. The saved name keeps a readable part of the original: `Q3 report.pdf` is saved as `paste-...-Q3-report.pdf`. The button in the Compose and paste dialogs now says Attach.
+
+ **Previous and Next Session are rebindable (#617).** Previous Session (Alt/Option+[) and the alternate Next Session (Alt/Option+]) are now ordinary rows in App Settings > Shortcuts, so you can rebind or switch them off like everything else. Capturing a chord another action already uses is refused with a message naming that action (Alt+1 to 9, Ctrl/Shift+Enter and Ctrl/Cmd+V stay reserved), and re-enabling or resetting a shortcut runs the same check. A chord you rebound no longer also types into the terminal: a rebind to Alt+J used to switch tabs and send ESC j to the session too. Built-in defaults like Ctrl+L still reach the CLI.
+
+ **A note after each update (#618).** The first time a browser loads a newer Codeman, a toast lists that release's headline changes, with a **See what's new** button that opens the full release notes. It shows once per version, and a browser that has never opened Codeman before just records the version and stays quiet, so the first note you will see is the one for the release after this one. The headlines are taken from the changelog when the release is cut.
+
+ **Multi-user mode: only admins change server-wide settings.** In multi-user mode any signed-in regular user could rewrite the server's settings (feature switches, run mode, case order, the default model for cron and the orchestrator, the app config), start a self-update, rotate the tunnel QR code, clear stale session state, and sign every user out with Revoke All, admins included. They could also read the Deepgram API key an admin saved. Those writes and operations now answer 403 to a regular user, Revoke All signs them out of their own logins only, and their settings read leaves out the admin's key and case names. In the browser a regular user's per-device settings still save, a change to a server-wide setting shows one notice that only an admin can change it, instead of pretending it was saved, and their own run mode and respawn presets are kept on their device. Single-user mode is unchanged.
+
+ **Fixes.**
+ - **Codex submits long prompts (#628).** Prompts sent through the API, `codeman agent send`, the MCP server, cron or the agent skill go into the pane as one bracketed paste followed by a separate Enter, so Codex no longer leaves them sitting unsent in its composer. A single character (a menu answer like `3` or `y`) is still typed, so numbered dialogs keep picking the option you sent, and a trailing `;` survives, so `find . -exec rm {} \;` arrives whole.
+ - **New Claude sessions in an untrusted folder (#634, #635).** An agent waiting for a fresh session to go idle no longer gets its answer while the workspace trust dialog is still up, so its first prompt can no longer press Enter on the highlighted "No, exit" and kill the session it just started. And the dialog is now answered every time: a screen read that came too soon after the previous one used to be dropped, which now and then left a new session sitting on the dialog. Deleting a session while the dialog is up also stops Codeman reading its dead pane for seconds afterwards.
+ - **Nested tmux colours after a history reload (#632).** Opening a remote or Docker case, or a shell running tmux inside it, no longer hands the nested tmux status bar's green background to whatever the agent prints next.
+ - **The codeman skill with arguments (#642).** The skill works again when Claude Code loads it with arguments ("use codeman to start 5 claude workers"). Claude Code swaps a skill's `$1` and `$2` for words of your request, which broke the pasted helpers; they now use `${1}` and `${2}`, and a cached copy written by the broken version is rewritten on the next run.
+ - **DeepSeek installs ready to run (#616).** Installing the terminal profile finds the `pnpm` next to `dsh` even when the server's own PATH lacks `~/.local/bin`, and the DeepSeek install command adds `pnpm` when the machine has none.
+ - **Unsupported videos refused up front (#622).** An Android `.3gp` or an `.mkv` gets "Unsupported file type" right away, instead of uploading up to a gigabyte and failing at the end.
+ - **Statusline reports (#623).** The prompt-cache readout is stored only for CLIs whose statusline reports one (Claude today), like the model badge, and a Claude report with more than 20 cache-miss causes keeps the first 20 instead of being refused, which used to blank the footer and the plan-usage chip.
+
+ **Fixes applied while landing.** Removing a worktree that is, sits inside or holds a Docker case's workspace is refused before git runs; it would have deleted the folder a container still mounts (#625). Codex's trust prompt also reads as blocked in the variant reached through its "Update available" screen, which ends in "esc back" (#624). The DeepSeek install only adds `pnpm` when none is on PATH, so a machine with a corepack `pnpm` keeps it instead of the whole install failing (#616). Three statements in the upload docs were corrected: only a local agent and a Docker case share the uploads folder (remote sessions are refused), GIFs keep their frames instead of being re-encoded, and a developer note no longer names a release that never shipped (#621). The update note skips the Thanks list and group labels like "Fixes.", still works for a release with no headlines, and is translated for zh-CN (#618).
+
+ **Docs.** The README and its Chinese translation caught up with 1.41.0 and 1.41.1 and show a sharper hero GIF, the Codeman Skill got a wiki page and two GIFs linked from both READMEs, CLAUDE.md was split into the root file, `src/web/CLAUDE.md` and `docs/releasing.md`, the release steps now include the MCP registry publish, and a new note says where video and GIF masters live (not on GitHub).
+
+## 1.41.1
+
+### Patch Changes
+
+- ### Thanks
+ - @timkjr for four PRs: shells that reload from tmux's rendered history instead of replaying old full-screen repaints (#601), the full scrollback of a remote SSH session read from the remote tmux (#602) with no rows lost at the join (#609), and a phone shell Paste that inserts a multi-line block instead of running it line by line (#600).
+ - @aakhter for three PRs: Codex panes that page their transcript with the wheel and touch (#527, fixes #508), new and spawned tabs that land in tab groups (#528), and a Focus section of shortcuts at the top of the vertical rail (#582).
+ - @JDProfresh for three PRs: Claude Code's prompt-cache state per session (#607, step 1 of #554), the phone Attach button with camera-roll video uploads (#606, part two of #553), and an upload that no longer stalls the server on a dead network mount (#605).
+ - @Randalix for clickable `~/`, relative and hard-wrapped file paths plus a folder grid for browsing a whole folder of renders (#604, closes #603).
+ - @opticon454 for three PRs: a Working folder row with a Copy button in Session Options (#593), tab colour, pin and image watcher surviving a restart (#591), and Link Existing saying why it refused a case (#590).
+ - @ideaCompany, a first-time contributor, for rendering local HTML files as real pages in the File Viewer (#547), and for the careful security write-up behind it.
+ - @zomby1, a first-time contributor, for OSC 52 clipboard support (#611).
+ - @Hugo-DDT, a first-time contributor, for two PRs: MCP server sync reading Gemini's bare `url`, OpenCode's V2 layout and `opencode.jsonc` (#599, fixes #586), and DeepSeek permission prompts that no longer offer Approve/Deny buttons that cannot work (#610, from #598).
+
+ **HTML files open as pages, and markdown opens rendered from the terminal (#547).** Click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer now shows it as a real page, with its own scripts, styles, images and data files from its folder, instead of raw source. A **Page** pill switches to the source and its Edit button. The page runs in a sandbox with an opaque origin: it cannot read Codeman's cookies, use Codeman's API or read files outside its own folder. Network access is open, so a dashboard that loads Chart.js or fonts from a CDN works as it would opened from disk. Broad folders (`/`, home, `/tmp`, a drive's mount point) are refused, so put a report in a folder of its own. And a `.md` or `.markdown` path clicked in the terminal now opens the rendered markdown viewer everywhere (main terminal, tiles, split pane, touch), instead of the live log viewer that showed "streaming"; the MD pill still gives the source. Logs, `.txt`, `.json` and code keep the live log viewer.
+
+ **`codeman mcp`: drive Codeman from any MCP client (#615).** A stdio Model Context Protocol server with seven tools (list, start, send, wait, read, interrupt, delete), so Claude Code, Codex, Gemini CLI, Cursor or Claude Desktop can start worker sessions in any installed agent CLI, prompt them, wait for them and read what they did. It is a client of the existing HTTP API with the same guards as `codeman agent`: it refuses control characters in input, never acts on the session it runs in, and by default only touches sessions it started. Setup lines for Claude Code, JSON clients and Codex are in the README and the wiki page "Driving Codeman From An Agent".
+
+ **The codeman skill for every agent CLI (#613).** With **Codeman Agent Skill** on (App Settings > Agents & CLIs > All agent CLIs, still off by default), Codex, Gemini CLI, OpenCode, Antigravity, Grok Build, DeepSeek Harness and OMP sessions now get the same `codeman` skill Claude Code had, in `.agents/skills/codeman`, so an agent in any mode knows how to start and manage worker sessions. `codeman skill install --mode ` installs it by hand.
+
+ **Tiles: move them by hand, or tear one off into its own window (#584).** With the camera gesture overlay on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it. With the new **Detach Tiles** setting (App Settings > Appearance, off by default), drag a tile's header out of the window and it pops out into its own window; drop it on another Codeman window's tiles and it moves there. Known limits: all Codeman windows in one browser share one saved tile layout, a drag between two browser profiles is not seen by the source window, and a drag-out held for more than a few seconds can be blocked as a pop-up.
+
+ **Focus section on the vertical rail (#582).** "Add to Focus" in a row's menu (or right-click a web tab) pins a shortcut to that tab in a small collapsible section at the top of the rail. The tab itself stays where it is.
+
+ **Tabs land in groups (#528).** A tab group's menu has **New session**, which starts a session straight into that group. A session an agent spawns lands right after its parent; App Settings > Appearance > Tabs > **Spawned tabs follow their parent** (off by default) keeps it moving with the parent.
+
+ **Folder grid and more clickable paths (#604).** Paths written as `~/...`, relative to the session folder, or broken across terminal lines are now clickable in the terminal and the Response Viewer, and folders are too: a folder opens a grid of its files (renders, screenshots, reports) that you can page through with the arrow keys.
+
+ **Phone Attach button and videos (#606).** On a phone the toolbar has an Attach button that opens the system file sheet directly, without raising the keyboard, and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into `.codeman-uploads/` like images.
+
+ **OSC 52 clipboard (#611).** A program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) now puts the text on your browser's clipboard, in the main terminal and in every tile. Read requests are refused, and a replayed buffer never copies.
+
+ **Prompt-cache readout (#607).** Claude sessions show whether their prompt cache is warm (`cache:until HH:MM`) or cold in the footer and in Session Options > Respawn, restored after a restart, with a reason when a launch will never report it. Display only, nothing changes behaviour.
+
+ **Faster tab switches to fullscreen Claude (#588, #583).** Switching back to a fullscreen Claude tab now loads only its current screen (a few KB) instead of replaying a 1 MB tail of old redraws (about 1.3 to 1.7 s saved per switch). The "Showing the most recent 1.0 MB" bar now appears only when there really is older history and you scroll to the top looking for it, gives a line count, and stays closed once you close it.
+
+ **Fixes.**
+ - **Shells reload cleanly (#601).** A shell tab, tile or split pane reloads from tmux's rendered history, so a prompt no longer appears four or five times and lines no longer go missing after a resize or a full-screen program.
+ - **Remote SSH sessions keep their scrollback (#602, #609).** Loading a remote session's full history reads it from the remote tmux in one bounded ssh round trip, where the local pane kept only a fraction (48 of 279 lines in one measurement, none after a reattach).
+ - **Codex pages with the wheel and touch (#527).** A Codex pane on its alternate screen pages its transcript with PageUp/PageDown from the first wheel notch or swipe, in the main terminal and in tiles, and never pages into an open permission dialog.
+ - **openpyxl spreadsheets preview (#612).** An `.xlsx` written by openpyxl with a cell comment, a table or a chart now opens in the File Viewer instead of failing.
+ - **Phone shell Paste (#600).** A multi-line paste in a shell is inserted as one bracketed paste and waits for Enter, instead of running each line as it arrives.
+ - **Restart keeps tab colour, pin and image watcher (#591).**
+ - **Session Options shows the working folder (#593)**, with a Copy button and a label that says when the folder is on a remote host or mounted into a Docker container.
+ - **Link Existing says why (#590)**, for example "Invalid case name format", instead of "Invalid request body".
+ - **Uploads on a dead mount (#605).** A prompt upload into a workspace on a network mount that stopped answering is refused at once instead of tying up a server thread.
+ - **MCP server sync (#599).** Gemini servers with a bare `url` sync as HTTP, OpenCode's V2 `mcp.servers` layout and `opencode.jsonc` are read.
+ - **DeepSeek approvals (#610).** DeepSeek Harness permission prompts show "Answer in the terminal" in the Approvals Inbox, the phone NEEDS YOU strip and push notifications, instead of Approve/Deny buttons the server always refused.
+
+ **Fixes applied while landing.** Remote SSH and Docker tabs keep their scrollback on a tab switch (they were about to lose it with #588). No colour bleed where remote history meets the live screen (#602, #609). A bare `~` in a Docker case no longer lists the host's home folder, and shell prompts (`user@host:~/proj$`) no longer turn into broken folder links (#604). Spawned sibling tabs keep their creation order (#528). An OSC 52 copy over plain HTTP gives keyboard focus back to the terminal (#611). A refused video upload answers instead of hanging, and the zh-CN "Attach" label of the tile grid is back (#606). The Working folder row fits on phones, with zh-CN text (#593). OpenCode sync no longer stalls on a server named `servers` (#599). Moving a tile to another window no longer overwrites that window's saved layout (#584). HTML pages: a `~/` link to an `.html` file renders as a page instead of falling back to source, `/tmp` is refused even when `TMPDIR` points elsewhere, symlinks cannot get around the hidden-file and file-type rules, workspace confinement is re-checked on every request, media in a page can seek, and the service worker never caches a page (#547). Codex users of `codeman mcp`: the config now forwards the session id, so its self-protection works (#615).
+
+## 1.41.0
+
+### Minor Changes
+
+- 87f1c9c: ### Thanks
+ - @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
+ - @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
+ - @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
+ - @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
+
+ 
+
+ **Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
+
+ **Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
+
+ **The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
+
+ **`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
+
+ **Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
+
+ **MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
+
+ **Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
+
+ **CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
+
+ **Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
+
+ **Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
+
+ **Fixes.**
+ - **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
+ - **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
+ - **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
+ - **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
+ - **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
+ - **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
+ - **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
+ - **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
+
+ **For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
+
+ **Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
+
+## 1.40.0
+
+### Minor Changes
+
+- ### Thanks
+ - @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
+ - @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
+ - @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
+ - @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
+ - @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
+
+ This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
+
+ **The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
+
+ **Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
+
+ **A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
+
+ **CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
+
+ **Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
+
+ **Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
+
+ **A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
+
+ **Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
+
+ **Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
+
+ **Spreadsheets in the File Viewer (#502).** `.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
+
+ **Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
+
+ **Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
+
+ **Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
+
+ **Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
+
+ **Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
+
+ **Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
+
+ **For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
+
+ **A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
+
+ **Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
+
+## 1.35.0
+
+### Minor Changes
+
+- 6f88e40: ### Thanks
+ - @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
+ - @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
+
+ **Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
+
+ **Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w-` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
+
+ **Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
+
+ **Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
+
+ **Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
+
+ **An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
+
+ **Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
+
+## 1.34.0
+
+### Minor Changes
+
+- 6aecc3b: ### Thanks
+ - @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
+ - @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
+ - @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
+ - @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
+
+ **Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
+
+ **MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
+
+ **Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
+
+ **Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model ` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=`), and it survives respawn and resume.
+
+ **Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
+
+ **iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
+
+ **Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
+
+ **Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
+
+ **Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
+
+## 1.33.3
+
+### Patch Changes
+
+- f776ad8: ### Thanks
+ - @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
+ - @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
+ - @irisitymichaelgrundberg for the `#session=` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
+ - @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
+
+ **Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
+
+ **Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
+
+ **Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
+
+ **Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
+
+ **Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
+
## 1.33.2
### Patch Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index 539f9b32..4f350fdb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,14 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
>
> **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it.
>
-> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
+> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `vitest.config.ts` (a one-line re-export of `config/vitest.ci.config.ts`: vitest finds its config by walking up from cwd, so without it a bare `npx vitest` ran with no setup file and wrote into the real `~/.codeman`, #640), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those.
## Quick Reference
| Task | Command |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
-| Type check | `npm run typecheck` (= `tsc --noEmit`) |
+| Type check | `npm run typecheck` (= `tsc --noEmit` + `tsc -p config/tsconfig.scripts.json`) |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Tests | `npm test` (the CI gate — safe to run bare) · one file: `npm test -- test/.test.ts` · see Testing for the excluded suites |
@@ -44,47 +44,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
3. **Only after verification passes**, proceed with COM
-The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
-⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
+⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed::` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
@@ -562,9 +662,9 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one.
-⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx ` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all.
+⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx ` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. The same rule covers the slot check: `/props` supplies `total_slots` only when `cmd` has no `--parallel`, and it NEVER widens the pool the slots divide (an earlier cut let a larger `/props` `n_ctx` become the pool and skip the warning; the 154112-vs-16384 measurement is exactly why that is wrong). `/props` refreshes llama-swap's last-use time for the model, so `discoverModels` probes it once per launch command (`propsByCmd`, in memory, keyed by host and model id and compared with `/running`'s `cmd`); probing on every 5-minute sweep would keep a model with a `ttl` loaded forever. With an explicit `--parallel N` llama.cpp reports `n_ctx` per slot, so a `/props` context beside it is stored with a slot count of 1: it is not divided a second time, and the 1 overwrites a count an earlier discovery round stored (`applyDiscoveredModels` merges onto what was learned before, so recording no count would keep the stale one).
-⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT.
+⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, or when `contextLength / slotCount` (`perSlotContext()`, from `modelSlotCounts`; an unknown count is one slot) is below it, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when it is above 1) instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT.
⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasCompletedOnboarding` and this session's own `projects[workingDir].hasTrustDialogAccepted` merge into the same `.claude.json` the API-key trust file already writes to, and `skipDangerousModePermissionPrompt` merges into `settings.json` (a different file, same corrupt-tolerant merge).
@@ -572,7 +672,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
### Approvals Inbox
-**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
+**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409).
@@ -580,7 +680,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert.
-⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it.
+⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the four selections the APP makes (boot restore, a solo window opening its target, a `#session=` link from another page, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it.
⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it.
@@ -602,6 +702,20 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
+### Agent inbox
+
+**Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column.
+
+⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost.
+
+⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a non-admin caller from naming another owner's session (an admin passes `canAccessOwned` for every session, so an admin can act as any session of the receiver's owner); in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs.
+
+⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only.
+
+⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock.
+
+⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`.
+
### Voice dictation via Claude
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses.
@@ -626,7 +740,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Raw file bodies: streamed and range-aware
-**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
+**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//...` (so a rendered page's own `
@@ -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.
@@ -106,10 +107,10 @@ terminal.onData((data) => {
}
});
-// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
-terminal.onWriteParsed(() => {
- if (zerolag.hasPending) zerolag.rerender();
-});
+// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
+// Unconditional: rerender() is a no-op when there is nothing to draw, and
+// hasPending would miss an overlay that shows only an IME composition.
+terminal.onWriteParsed(() => zerolag.rerender());
```
That is the whole integration. Everything below is for tuning it.
@@ -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
---
@@ -186,7 +187,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
```
-`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
+`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
---
@@ -202,8 +203,10 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|--------|---------|-------------|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
| `appendText(text)` | `void` | Append multiple characters (paste). |
-| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
-| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
+| `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
@@ -217,6 +220,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
+### IME composition
+
+While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
+
+```typescript
+const textarea = terminal.textarea!;
+textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
+textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
+// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
+```
+
+The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
+
### Flushed text
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
@@ -242,7 +258,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Method | Description |
|--------|-------------|
-| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
+| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
### Prompt
@@ -258,7 +274,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Property | Type | Description |
|----------|------|-------------|
| `pendingText` | `string` | Unacknowledged text (read-only) |
-| `hasPending` | `boolean` | `true` if the overlay has any content |
+| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
+| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
### Options
@@ -493,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
@@ -522,13 +547,13 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
### Scroll awareness
-The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
+The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
---
## 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 655f9381..ac77f48e 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.3.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 d88c8743..baa1bab2 100644
--- a/packages/xterm-zerolag-input/src/overlay-renderer.ts
+++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts
@@ -46,18 +46,66 @@ 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 {
lines,
+ compositionStart,
startCol,
totalCols,
cellW,
@@ -90,12 +138,27 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// `startCol` indents only the line that begins at the prompt marker, so it is
// dropped along with that line when the tail is all that fits.
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
+ // 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);
+ lineLengths.push(length);
+ offset += length;
+ }
+ }
let visibleLines = lines;
+ let firstVisible = 0;
let keepsPromptLine = true;
let topRow = promptRow;
if (rows && rows > 0) {
if (lines.length > rows) {
- visibleLines = lines.slice(lines.length - rows);
+ firstVisible = lines.length - rows;
+ visibleLines = lines.slice(firstVisible);
keepsPromptLine = false;
topRow = 0;
} else if (promptRow + lines.length > rows) {
@@ -107,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++) {
@@ -116,9 +200,38 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
- const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
- container.appendChild(lineEl);
+ // 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,
+ topPx,
+ widthPx,
+ cellH,
+ cellW,
+ charTop,
+ charHeight,
+ font,
+ terminal,
+ lineCompositionFrom
+ );
+ 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) {
@@ -134,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;
}
}
@@ -141,10 +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.
*
- * Each character gets its own `` positioned by visual column offset.
- * CJK wide characters occupy 2 cell widths.
+ * 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.
+ *
+ * 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,
@@ -156,10 +280,13 @@ function makeLine(
_charTop: number,
_charHeight: number,
font: FontStyle,
- terminal?: XtermTerminal | null
+ terminal?: XtermTerminal | null,
+ 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';
@@ -169,29 +296,33 @@ 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', '');
+ span.setAttribute('aria-hidden', 'true');
+ }
span.textContent = ch;
el.appendChild(span);
- colOffset += cw;
+ edge = nextEdge;
+ index++;
}
return el;
diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts
index 9d660ede..0ebd2f40 100644
--- a/packages/xterm-zerolag-input/src/prompt-finder.ts
+++ b/packages/xterm-zerolag-input/src/prompt-finder.ts
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
return '';
}
}
+
+/**
+ * Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
+ * `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
+ * the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
+ */
+export function promptRowInViewport(terminal: XtermTerminal): boolean {
+ try {
+ const buf = terminal.buffer.active;
+ if (buf.viewportY === buf.baseY) return true;
+ if (typeof buf.cursorY !== 'number') return false;
+ const cursorRow = buf.baseY + buf.cursorY;
+ return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts
index bb9f7fe1..e81d610d 100644
--- a/packages/xterm-zerolag-input/src/types.ts
+++ b/packages/xterm-zerolag-input/src/types.ts
@@ -163,6 +163,12 @@ export interface CellDimensions {
/** Parameters for the overlay renderer. */
export interface RenderParams {
lines: string[];
+ /**
+ * Index (in code points, across all `lines`) where IME composition text
+ * begins. Characters from there on are drawn underlined and marked
+ * `data-zerolag-composition`. Omit when nothing is being composed.
+ */
+ compositionStart?: number;
startCol: number;
totalCols: number;
cellW: number;
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
index b1e4105b..e1320a98 100644
--- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -8,7 +8,7 @@ import type {
FontStyle,
} from './types.js';
import { getCellDimensions } from './cell-dimensions.js';
-import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
+import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
@@ -67,6 +67,11 @@ export class ZerolagInputAddon implements XtermAddon {
private _flushedOffset = 0;
private _flushedText = '';
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 = '';
@@ -120,17 +125,16 @@ export class ZerolagInputAddon implements XtermAddon {
// Cache font properties
this._cacheFont();
- // Scroll detection: hide overlay when scrolled away from bottom
+ // Scroll detection: hide the overlay while the cursor row is scrolled out of view
this._scrollHandler = () => {
try {
- const buf = this._terminal!.buffer.active;
- if (buf.viewportY !== buf.baseY) {
+ if (!promptRowInViewport(this._terminal!)) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
this._scrollTimer = null;
}
- } else if (this._pendingText || this._flushedOffset > 0) {
+ } else if (this._hasContent()) {
if (this._scrollTimer) clearTimeout(this._scrollTimer);
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null;
@@ -206,8 +210,14 @@ export class ZerolagInputAddon implements XtermAddon {
* - `'flushed'`: A character was removed from text already sent to the PTY.
* The consumer SHOULD send backspace to the PTY.
* - `false`: Nothing to remove. The consumer should NOT send backspace.
+ *
+ * Any IME composition is dropped in every case, and the overlay is repainted
+ * without it (hidden when nothing else is left).
*/
removeChar(): 'pending' | 'flushed' | false {
+ // A backspace that reaches the overlay means no composition is open.
+ const droppedComposition = this._composition.length > 0;
+ this._composition = '';
if (this._pendingText.length > 0) {
this._pendingText = this._pendingText.slice(0, -1);
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
@@ -243,6 +253,9 @@ export class ZerolagInputAddon implements XtermAddon {
return 'flushed';
}
+ // Nothing to remove, but a composition-only overlay is still on screen
+ // drawing the text dropped above.
+ if (droppedComposition) this._hide();
return false;
}
@@ -252,6 +265,7 @@ export class ZerolagInputAddon implements XtermAddon {
*/
clear(): void {
this._pendingText = '';
+ this._composition = '';
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
@@ -297,7 +311,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearFlushed(): void {
this._flushedOffset = 0;
this._flushedText = '';
- if (this._pendingText) {
+ if (this._pendingText || this._composition) {
this._render();
} else {
this._hide();
@@ -312,12 +326,41 @@ export class ZerolagInputAddon implements XtermAddon {
* that move the prompt.
*/
rerender(): void {
- if (this._pendingText || this._flushedOffset > 0) {
+ if (this._hasContent()) {
this._lastRenderKey = '';
this._render();
}
}
+ /**
+ * 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.
@@ -325,7 +368,7 @@ export class ZerolagInputAddon implements XtermAddon {
refreshFont(): void {
this._cacheFont();
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._hasContent()) this._render();
}
// ─── Buffer detection ─────────────────────────────────────────────
@@ -391,7 +434,37 @@ export class ZerolagInputAddon implements XtermAddon {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._hasContent()) this._render();
+ }
+
+ // ─── IME composition ──────────────────────────────────────────────
+
+ /**
+ * Show text an IME is still composing as an underlined tail after the
+ * pending text, wrapped and kept on screen like the rest of the overlay.
+ * Pass `''` to remove it.
+ *
+ * Visual only: the composition is never part of `pendingText`, `hasPending`
+ * or anything a consumer sends. When the IME commits, the consumer adds the
+ * committed text the usual way (`addChar`/`appendText`) and clears the
+ * composition. `clear()` and `removeChar()` drop it too.
+ */
+ setComposition(text: string): void {
+ // One visual line of provisional text: control characters and line breaks
+ // would break the cell grid.
+ const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
+ if (next === this._composition) return;
+ this._composition = next;
+ if (this._hasContent()) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
+ /** Text an IME is still composing, drawn after `pendingText` (never sent). */
+ get composition(): string {
+ return this._composition;
}
// ─── Prompt utilities ─────────────────────────────────────────────
@@ -425,7 +498,13 @@ export class ZerolagInputAddon implements XtermAddon {
return this._pendingText;
}
- /** Whether there is any overlay content (pending or flushed). */
+ /**
+ * Whether there is pending or flushed text. Excludes the IME composition,
+ * which is never sent, so an overlay showing only a composition reports
+ * `false` while still on screen. To re-place the overlay after output or a
+ * resize, call `rerender()` unconditionally: it is a no-op when there is
+ * nothing to draw.
+ */
get hasPending(): boolean {
return this._pendingText.length > 0 || this._flushedOffset > 0;
}
@@ -443,6 +522,10 @@ export class ZerolagInputAddon implements XtermAddon {
// ─── Private methods ──────────────────────────────────────────────
+ private _hasContent(): boolean {
+ return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
+ }
+
private _getPromptOffset(): number {
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
return prompt.offset ?? 2;
@@ -505,7 +588,7 @@ export class ZerolagInputAddon implements XtermAddon {
private _render(): void {
if (!this._terminal || !this._overlay) return;
- if (!this._pendingText && !(this._flushedOffset > 0)) {
+ if (!this._hasContent()) {
this._overlay.style.display = 'none';
return;
}
@@ -513,8 +596,8 @@ export class ZerolagInputAddon implements XtermAddon {
try {
const buf = this._terminal.buffer.active;
- // Hide overlay when scrolled up — prompt is at bottom, not in viewport
- if (buf.viewportY !== buf.baseY) {
+ // Hide the overlay while the cursor row is scrolled out of view
+ if (!promptRowInViewport(this._terminal)) {
this._overlay.style.display = 'none';
return;
}
@@ -563,12 +646,18 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
+ // The composition is a styled tail after everything the user has typed.
+ const compositionStart = [...displayText].length;
+ displayText += this._composition;
+
// 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}:${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;
@@ -608,6 +697,7 @@ export class ZerolagInputAddon implements XtermAddon {
renderOverlay(this._overlay, {
lines,
+ compositionStart: this._composition ? compositionStart : undefined,
startCol,
totalCols,
cellW,
@@ -615,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 57b815e9..d13fa789 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
new file mode 100644
index 00000000..9a06b708
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/composition.test.ts
@@ -0,0 +1,238 @@
+import { describe, it, expect, afterEach } from 'vitest';
+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
+// after the pending text. Visual only, never part of what a consumer sends.
+
+const CELL_W = 10;
+
+let cleanups: (() => void)[] = [];
+
+afterEach(() => {
+ for (const fn of cleanups) fn();
+ cleanups = [];
+});
+
+function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
+ const mock = createMockTerminal({
+ buffer: { lines: opts.lines ?? ['$ '] },
+ cols: opts.cols,
+ rows: opts.rows,
+ cellWidth: CELL_W,
+ cellHeight: 20,
+ });
+ 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, mock, overlay };
+}
+
+/** Line divs of the overlay (the block cursor is a bare span, not a div). */
+function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
+ return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
+}
+
+function lineText(line: HTMLDivElement): string {
+ return Array.from(line.children)
+ .map((s) => s.textContent)
+ .join('');
+}
+
+function compositionText(overlay: HTMLDivElement): string {
+ return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
+ .map((s) => s.textContent)
+ .join('');
+}
+
+describe('setComposition', () => {
+ it('renders the composition after pendingText, underlined and aria-hidden', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+
+ const [line] = lineDivs(overlay);
+ expect(lineText(line)).toBe('abcxy');
+ const spans = Array.from(line.children) as HTMLSpanElement[];
+ for (const span of spans.slice(0, 3)) {
+ expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
+ expect(span.style.textDecoration).toBe('');
+ }
+ for (const span of spans.slice(3)) {
+ expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
+ expect(span.getAttribute('aria-hidden')).toBe('true');
+ expect(span.style.textDecoration).toBe('underline');
+ }
+ // Grid positions continue straight on from the pending text.
+ 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('');
+ });
+
+ it('places a wide composition by cell width after wide pending text', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('今日は');
+ addon.setComposition('天気');
+ const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
+ expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
+ 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', () => {
+ const { addon } = setup();
+ addon.appendText('abc');
+ addon.setFlushed(2, 'zz');
+ addon.setComposition('xy');
+ expect(addon.pendingText).toBe('abc');
+ expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
+ expect(addon.composition).toBe('xy');
+ expect(addon.state.pendingText).toBe('abc');
+ expect(addon.state.flushedText).toBe('zz');
+ });
+
+ it('shows on an empty prompt without making anything pending', () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('かな');
+ expect(addon.pendingText).toBe('');
+ expect(addon.hasPending).toBe(false);
+ expect(addon.state.visible).toBe(true);
+ expect(compositionText(overlay)).toBe('かな');
+ });
+
+ it('wraps with the pending text: the tail continues onto the next line', () => {
+ // 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
+ const { addon, overlay } = setup({ cols: 12 });
+ addon.appendText('abcdefgh');
+ addon.setComposition('WXYZ');
+ const lines = lineDivs(overlay);
+ expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
+ 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(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', () => {
+ // 2 visible rows, 3 lines of text: the first line is dropped.
+ const { addon, overlay } = setup({ cols: 6, rows: 2 });
+ addon.appendText('abcdefghij');
+ addon.setComposition('XYZ');
+ const lines = lineDivs(overlay);
+ expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
+ expect(compositionText(overlay)).toBe('XYZ');
+ const first = Array.from(lines[0].children);
+ expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
+ });
+
+ it("setComposition('') removes the tail and keeps the pending text", () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.setComposition('');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abc');
+ expect(compositionText(overlay)).toBe('');
+ expect(addon.pendingText).toBe('abc');
+ });
+
+ it("setComposition('') on an otherwise empty overlay hides it", () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('xy');
+ addon.setComposition('');
+ expect(overlay.style.display).toBe('none');
+ expect(overlay.innerHTML).toBe('');
+ });
+
+ it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.clear();
+ expect(addon.composition).toBe('');
+ expect(overlay.style.display).toBe('none');
+ addon.addChar('q');
+ expect(lineText(lineDivs(overlay)[0])).toBe('q');
+ });
+
+ it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('ab');
+ expect(addon.composition).toBe('');
+ expect(lineText(lineDivs(overlay)[0])).toBe('ab');
+ });
+
+ it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('ka');
+ expect(compositionText(overlay)).toBe('ka');
+ expect(addon.removeChar()).toBe(false);
+ expect(addon.composition).toBe('');
+ expect(compositionText(overlay)).toBe('');
+ expect(overlay.style.display).toBe('none');
+ expect(addon.state.visible).toBe(false);
+ });
+
+ it('removeChar() repaints flushed text without the dropped composition', () => {
+ const { addon, overlay } = setup();
+ addon.setFlushed(3, 'abc');
+ addon.setComposition('xy');
+ expect(addon.removeChar()).toBe('flushed');
+ expect(compositionText(overlay)).toBe('');
+ expect(lineText(lineDivs(overlay)[0])).toBe('ab');
+ });
+
+ it('text appended while composing lands before the tail', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('ab');
+ addon.setComposition('xy');
+ addon.addChar('c');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ expect(compositionText(overlay)).toBe('xy');
+ });
+
+ it('rerender() and refreshFont() keep the composition', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.rerender();
+ expect(compositionText(overlay)).toBe('xy');
+ addon.refreshFont();
+ expect(compositionText(overlay)).toBe('xy');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ });
+
+ it('re-renders when only the composition changes', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('x');
+ addon.setComposition('xy');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ });
+
+ it('strips control characters and line breaks from the composition', () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('a\nb\u0007c ');
+ expect(addon.composition).toBe('abc');
+ expect(compositionText(overlay)).toBe('abc');
+ });
+
+ it('draws the block cursor after the composition', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('ab');
+ addon.setComposition('xy');
+ const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
+ // prompt col 0 + offset 2 + 4 cells
+ expect(cursor.style.left).toBe(6 * CELL_W + 'px');
+ });
+});
diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts
index d2229576..878dffb9 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 a95846a8..a83d0901 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/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
index 69942a31..4c09ff04 100644
--- a/packages/xterm-zerolag-input/test/prompt-finder.test.ts
+++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
-import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
+import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
cleanup();
});
});
+
+describe('promptRowInViewport', () => {
+ const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
+ ({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
+
+ it('is true at the bottom regardless of the cursor', () => {
+ expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
+ });
+
+ it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
+ // scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
+ expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
+ // cursor exactly on the last visible row
+ expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
+ });
+
+ it('is false once the cursor row is scrolled out of the viewport', () => {
+ expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
+ expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
+ });
+
+ it('keeps the bottom-only rule when the buffer has no cursorY', () => {
+ expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
+ });
+});
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 74063761..4718cb25 100644
--- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
+++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
@@ -725,3 +725,109 @@ describe('ZerolagInputAddon', () => {
});
});
});
+
+describe('viewport scrolled away from the bottom', () => {
+ function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
+ const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
+ 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, mock, overlay };
+ }
+
+ it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
+ // The host parks the viewport to keep trailing blank rows out of view; the
+ // prompt and cursor are still visible, so the user's text must be too.
+ const { addon, overlay } = parked(0, 1, 0);
+ addon.appendText('abc');
+ expect(addon.pendingText).toBe('abc');
+ expect(overlay.style.display).not.toBe('none');
+ expect(overlay.textContent).toContain('abc');
+ });
+
+ it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
+ const { addon, overlay } = parked(0, 30, 0);
+ addon.appendText('abc');
+ expect(addon.pendingText).toBe('abc');
+ 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 93cb5488..69089733 100644
--- a/plugins/codeman/.claude-plugin/plugin.json
+++ b/plugins/codeman/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "codeman",
- "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
- "version": "1.33.2",
+ "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.48.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/plugins/codeman/README.md b/plugins/codeman/README.md
index 9ea8f05e..06d50222 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 c2aac7da..755ade18 100644
--- a/plugins/codeman/skills/codeman/SKILL.md
+++ b/plugins/codeman/skills/codeman/SKILL.md
@@ -1,17 +1,26 @@
---
name: codeman
description: >-
- Drive Codeman, the session manager this agent is running inside, over its HTTP API:
- list sessions, start worker sessions, send them prompts, block until they finish
- (wait / wait-output / send-and-wait), 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, watch
- another session, or start and manage workers. Only usable inside a Codeman-managed
- session (CODEMAN_MUX=1); refuse to act otherwise.
+ Drive Codeman, the session manager this agent is running inside, over its HTTP API,
+ from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
+ Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
+ CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
+ 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, 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.
@@ -28,11 +37,44 @@ 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`, `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.
+
+- **Every call fails at once with "couldn't connect" or "connection refused".** Your
+ CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
+ does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
+- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
+ `404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
+ process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
+ the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
@@ -41,13 +83,14 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run).
-**Codeman seeds the preamble file for you** when it spawns a claude session (server
-1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
+**Codeman seeds the preamble file for you** when it spawns your session (claude since
+server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
+usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +118,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
-grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
-# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
+# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
+# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
+# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -85,7 +131,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
-envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
+envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -93,9 +139,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
-# draw the lineage. Set once here and every present and future create call carries it;
-# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
-# fail a spawn, so there is no case where you would want to leave it off.
+# draw the lineage. Set once here and every present and future create call carries it.
+# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
+# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -120,15 +166,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # -> "true"/"false". `shift+tab` is the one token
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -144,7 +190,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -162,7 +208,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
- t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -172,7 +218,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not
- local sid="$1" k i=1
+ local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -196,6 +242,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
+# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
+# it is set Claude Code's advisor tool: a stronger model the worker consults before
+# committing to an approach, on a recurring error and before declaring the task done.
+# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
+# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +258,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
- -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
+ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
- + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
+ # A server without advisor support DROPS the field instead of refusing it, so read it
+ # back: a worker silently missing the advisor it was asked for is worth one line.
+ if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
+ [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
+ echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
+ fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -301,6 +359,16 @@ spawn_workers() {
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
+ # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
+ # the TUI): the server pastes a multi-line prompt as one block, and the loop below,
+ # like the server's own submit check, cannot recognise a pasted block that is still
+ # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
+ # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
+ # joined with a space. For a long multi-line task, write a file and send its path.
+ case "$p" in *$'\n'*|*$'\r'*)
+ p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
+ esac
+ [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -361,7 +429,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
- t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
+ t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -372,10 +440,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
-CODEMAN_PREAMBLE=1.30.1
+CODEMAN_PREAMBLE=1.44.0
PREAMBLE
)
-. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
+. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -426,7 +494,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -493,6 +561,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
+- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
+ (`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
+ stronger model it consults before committing to an approach, on a recurring error and
+ before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
+ below the worker's own model is never attached (on an Opus worker only `opus` and
+ `fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
@@ -508,8 +582,9 @@ That is true of no other non-claude mode, and it is worth knowing why: the DeepS
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
-what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
-`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
+what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
+`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
+no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
@@ -542,11 +617,12 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
| resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) |
-| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
+| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
| watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) |
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
+| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack `. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -621,8 +697,9 @@ You are yourself a session on this server, and the API has **no undo**.
user's repository state, so say that you did; **removing** one discards any
uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)).
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
-- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user
- cap of 25 that fires the same 409). Case creation is uncapped and writes real
+- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and,
+ in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires
+ the same 409. Case creation is uncapped and writes real
directories. Clean up every session you start, and never retry `quick-start` in a
loop.
@@ -649,6 +726,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
+| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh
index 11982e34..52f4707d 100644
--- a/plugins/codeman/skills/codeman/preamble.sh
+++ b/plugins/codeman/skills/codeman/preamble.sh
@@ -1,4 +1,7 @@
-# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
+# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
+# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
-envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
+envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -15,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
-# draw the lineage. Set once here and every present and future create call carries it;
-# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
-# fail a spawn, so there is no case where you would want to leave it off.
+# draw the lineage. Set once here and every present and future create call carries it.
+# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
+# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # -> "true"/"false". `shift+tab` is the one token
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -66,7 +69,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -84,7 +87,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
- t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -94,7 +97,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not
- local sid="$1" k i=1
+ local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -118,6 +121,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
+# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
+# it is set Claude Code's advisor tool: a stronger model the worker consults before
+# committing to an approach, on a recurring error and before declaring the task done.
+# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
+# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +137,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
- -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
+ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
- + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
+ # A server without advisor support DROPS the field instead of refusing it, so read it
+ # back: a worker silently missing the advisor it was asked for is worth one line.
+ if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
+ [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
+ echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
+ fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -223,6 +238,16 @@ spawn_workers() {
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
+ # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
+ # the TUI): the server pastes a multi-line prompt as one block, and the loop below,
+ # like the server's own submit check, cannot recognise a pasted block that is still
+ # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
+ # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
+ # joined with a space. For a long multi-line task, write a file and send its path.
+ case "$p" in *$'\n'*|*$'\r'*)
+ p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
+ esac
+ [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -283,7 +308,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
- t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
+ t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -294,4 +319,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
-CODEMAN_PREAMBLE=1.30.1
+CODEMAN_PREAMBLE=1.44.0
diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md
index d8b26507..8a41cacf 100644
--- a/plugins/codeman/skills/codeman/reference/endpoints.md
+++ b/plugins/codeman/skills/codeman/reference/endpoints.md
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
-minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
-failing credential in a loop**: you will lock the address out of the login path for
+minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
+mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
+guesses per IP; both are checked before the password, so past either even a correct
+password from that address gets the 429 (for the per-account one, only for that
+account). From the 10th failed guess on, an account's wrong guesses also get the 429
+from every address, but a correct password still logs in. **Never retry a failing
+credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -113,7 +118,7 @@ Every JSON response: `{"success":true,"data":…}` or
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) |
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
| `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host |
-| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
+| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help |
@@ -237,7 +242,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
-`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
+`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -341,17 +346,24 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
-, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
+, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
+A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
+model id): Claude Code's advisor tool, a stronger model the worker consults before
+committing to an approach, on a recurring error and before declaring the task done. It is
+a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
+(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
+`CODEMAN_WORKER_ADVISOR` is set.
+
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
-`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
-Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
-`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
+`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
+Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
+`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -360,7 +372,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob
is absent, `jq -r` prints the literal string `null`, and every later call then targets
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap:
-the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
+the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap),
`NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`,
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
loop.
@@ -384,17 +396,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
-`envOverrides`). Three differences that break copied code:
+`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
+differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
- (`session-routes.ts:878` returns `{ session: lightState }`).
+ (the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
- `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
- (`session-routes.ts:648`).
+ `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default)
+ (`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
@@ -412,8 +425,9 @@ callers send no body at all.
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out),
the number-one silent failure.
-- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
- a dialog), send `{"input":"\r"}`.
+- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste
+ and the `\r` submits all lines (without `useMux`, multi-line input is written straight
+ into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`.
- `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one
is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000
character body passes validation and *then* 400s at the route against
@@ -478,7 +492,7 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
- `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
+ `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper:
@@ -599,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
+### Agent inbox: hand a worker text without typing into its pane
+
+Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
+receiver loop: `verbs.md` §5.15.
+
+| Task | Call |
+|------|------|
+| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
+| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
+| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
+| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
+| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
+
+`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
+preamble's `CURL` sends, never from the body.
+
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -626,8 +656,8 @@ Three bounded long-polls. Shared semantics:
|--------|---------|---------------|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
-| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
-| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only |
+| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
+| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
@@ -788,11 +818,11 @@ response echoes.
| `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright |
| `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` |
| wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line |
-| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start |
-| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message |
+| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start |
+| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
-| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
+| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
diff --git a/plugins/codeman/skills/codeman/reference/history.md b/plugins/codeman/skills/codeman/reference/history.md
new file mode 100644
index 00000000..ae087fb6
--- /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 00000000..8f454748
--- /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 2d7d68d0..99db822e 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) |
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
-| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
+| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a
the worker's messages arrive tagged `from-name=""`; a derived-name worker's
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
-peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
-is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
+peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose
+is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), because `--name` is also the conversation's
`/resume` title and terminal title and suppresses Claude's own generated title. A
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
-and an auto name are NOT passed, so such a worker's peer name is derived; use
+and an auto name are NOT passed (with `pinCliSessionName` off, the default), so such a worker's peer name is derived; use
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
only unsafe characters is dropped), and the docker/remote builders never see it at all
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
-Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
+Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md
index af814653..c03bb194 100644
--- a/plugins/codeman/skills/codeman/reference/recipes.md
+++ b/plugins/codeman/skills/codeman/reference/recipes.md
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
-# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
+# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -391,10 +391,10 @@ the worker remembering to print a token.
## Flow 5: watch for a worker stuck on a prompt
-Claude workers can block on a permission dialog. `blocked` is a wait signal
-(claude-mode only, and it needs Codeman's hooks in the worker's directory: see Flow 7
-step 4), so watch for it and surface the question to the user instead of guessing an
-answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
+Claude workers can block on a permission dialog. `blocked` is a wait signal (a claude
+worker needs Codeman's hooks in its directory for it: see Flow 7 step 4; a codex worker
+gets it from its screen, see below), so watch for it and surface the question to the user
+instead of guessing an answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
one (the same setting that decides whether the readiness marker in Flow 1 ever
appears):
@@ -409,10 +409,13 @@ if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
fi
```
-Where the worker has no hooks, `blocked` never fires and a stuck worker looks exactly
-like a slow one: your marker wait burns its whole bound. The fallback is the same
-terminal tail, taken when a bound runs out, and the same rule about not answering it
-yourself.
+Codex workers are the one exception without hooks: Codeman reads their approval,
+question and trust dialogs off the screen, so the same watch works with
+`until=blocked,exit` (codex has no `stop`, a `400`), and the dialog is answered in the
+terminal, never through the Approvals Inbox. Anywhere else without hooks, `blocked` never
+fires and a stuck worker looks exactly like a slow one: your marker wait burns its whole
+bound. The fallback is the same terminal tail, taken when a bound runs out, and the same
+rule about not answering it yourself.
## Flow 6: claude fan-out over messaging
diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md
index 46eecc8d..d72d46bc 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
@@ -95,11 +95,15 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
+ one that does not answer or cannot be read (an unreachable network mount, a
+ permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
+ create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
-`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the
-per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
+`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via
+`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap,
+25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
remote or docker host named by the case no longer exists), `OPERATION_FAILED` and
`INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on
`.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r`
@@ -300,10 +304,16 @@ duplicate resend costs nothing.
Codeman types the text and sends Enter **only when the input contains a carriage
return**; without it your command sits unsubmitted on the worker's prompt and
everything downstream times out. No response field catches this: `delivered:true`
-means "written to the pane", **not** "submitted". Newlines are stripped, so input is
-single-line by construction. Build the body with `jq -n` for any prompt you did not
-author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on
-the first double quote, backslash or `$` in a real prompt:
+means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the
+text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line,
+though:** its Enter loop and the server's submit check cannot reliably recognise a
+multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in
+Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space,
+which loses their layout; put long multi-line content in a file
+([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n`
+for any prompt you did not author as a literal, because the inline
+`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$`
+in a real prompt:
```bash
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
@@ -373,8 +383,10 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
-`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
-400, and lifecycle transitions there are coarse (a short shell command may emit **no**
+`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
+400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
+question or trust dialog is up; a wait for it answers at once if one already is), and
+lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
@@ -425,7 +437,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
-no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
+no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -511,7 +523,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
-`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
+`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
@@ -538,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.
@@ -582,9 +596,10 @@ allowlist in §4.
### 5.9 Big input via the workspace
-The composer is a single line capped at 65536 characters with newlines stripped, which
-makes it a bad channel for a spec, a diff or a file list. The workspace is the good
-one, and for a local or docker case you are on the same filesystem as the worker.
+The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed
+as submitted, which makes it a bad channel for a spec, a diff or a file list. The
+workspace is the good one, and for a local or docker case you are on the same
+filesystem as the worker.
1. Write `TASK.md` into the worker's workspace with your own file tools. The path is
`.data.casePath` from `quick-start`, or the `workingDir` you passed to
@@ -594,10 +609,10 @@ one, and for a local or docker case you are on the same filesystem as the worker
3. Wait on `DONE_` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)),
then read `RESULT.json` back with your own tools.
-This sidesteps the byte cap, the newline stripping and the quoting hazards in one
-move, and it makes the marker **split by construction**: the token lives in the file,
-never in the line you type, so the echo of your own keystrokes cannot match it. The
-worker also gets to re-read the task instead of holding it in one echoed line.
+This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards
+in one move, and it makes the marker **split by construction**: the token lives in the
+file, never in the line you type, so the echo of your own keystrokes cannot match it.
+The worker also gets to re-read the task instead of holding it in one echoed line.
⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose
filesystem you cannot see, and any worker **currently editing** the directory you are
@@ -694,7 +709,7 @@ The shape, each step verified live (probes, failure modes and safety detail in
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
`sessionName` in quick-start to pick it (a `w-` placeholder-shaped name is not
- pinned, so it lists derived); older setups list a name derived from the case folder.
+ pinned by default, so it lists derived); older setups list a name derived from the case folder.
No row = messaging is off for that worker (it is feature-flagged even on matching
CLI versions, observed live): fall back to the HTTP recipes without complaint.
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
@@ -751,3 +766,63 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
+### 5.15 Agent inbox
+
+Every session has an inbox. A message posted there is stored, not typed: the receiver's
+pane is untouched, no turn starts, and the text may span several lines. The receiver
+reads when it is ready. Use it for results a worker hands back, for a follow-up task a
+busy worker should pick up after its current one, and for anything longer than one
+composer line. Use `send` when you want a turn to start now.
+
+```bash
+codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
+codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
+codeman agent ack […] # remove what you handled, by the full id inbox printed
+codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
+codeman agent ls # INBOX column: pending (new), parked
+```
+
+The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
+/api/v1/sessions/:id/inbox?wait=`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
+`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
+`X-Codeman-Parent-Session`, which is how the server knows who you are.
+
+Rules that matter:
+
+- **Reading removes nothing.** Every read returns every pending message until you ack
+ it, so a crash between reading and doing loses nothing. Ack once the work is done,
+ not when you have read it: a sender looking at `ls` sees unacked mail as not done.
+- **Only the receiver's own read counts.** Your read of your own inbox marks messages
+ seen (`seen: true` on the next read); reading another session's inbox is a peek and
+ changes nothing for it. Only the receiver can ack (403 otherwise).
+- **`--wait` waits for something new.** It returns at once when there is unseen mail
+ and otherwise blocks until a post arrives; seen but unacked messages come back with it
+ and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
+ (a restart): loop again. Exit 3 means the session list no longer has the session.
+ `--wait` on someone else's inbox returns at once while they have unseen mail (only
+ their read clears it); check `ls` instead.
+- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
+ or `null` (the user). In single-user mode that is a hint, since every local process
+ holds the same credentials. In multi-user mode it is checked against the owner;
+ an admin can still act as any session of the receiver's owner.
+- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
+ inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
+ hours after they were posted.
+- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
+ server never types into its pane. When `ls` shows a worker idle with unread mail and
+ 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/scripts/build.mjs b/scripts/build.mjs
index 2ab8ecd5..d949fcad 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -4,6 +4,7 @@
* Extracted from the package.json one-liner for readability and debuggability.
*
* Steps:
+ * 0. Preflight: the build-time packages resolve (nothing is touched before it)
* 1. TypeScript compilation
* 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles
@@ -13,6 +14,7 @@
*/
import { execSync } from 'child_process';
+import { createRequire } from 'module';
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url';
@@ -25,6 +27,31 @@ function run(label, cmd) {
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
}
+// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
+// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
+// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
+// with dist/web/public already wiped, so the running server kept serving an index.html
+// whose hashed assets were gone. Keep the list in step with every require.resolve in
+// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
+// Only specifiers that resolve without an exports map in the way: a subpath of a package
+// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
+// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
+const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
+const requireFromBuild = createRequire(import.meta.url);
+const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
+ try {
+ requireFromBuild.resolve(specifier);
+ return false;
+ } catch {
+ return true;
+ }
+});
+if (missingModules.length > 0) {
+ console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
+ console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
+ process.exit(1);
+}
+
// 1. TypeScript compilation
run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js');
@@ -49,6 +76,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
+// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
+// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
+run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
appendFileSync(
@@ -83,11 +113,14 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
+run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
+run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
+run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -111,10 +144,13 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
+ 'mobile-ime-preview.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
+ 'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
+ 'header-divider.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
@@ -125,6 +161,7 @@ console.log('\n[build] content-hash cache busting');
'api-client.js',
'subagent-windows.js',
'image-input.js',
+ 'spreadsheet-preview.js',
'vendor/xterm-zerolag-input.js',
'vendor/xterm-predictive-echo.js',
];
diff --git a/scripts/check-public-assets.mjs b/scripts/check-public-assets.mjs
index a9b5daeb..3ef9ab97 100644
--- a/scripts/check-public-assets.mjs
+++ b/scripts/check-public-assets.mjs
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
-import { readdirSync, readFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
+// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
+// They load only inside the spreadsheet worker, but a dependency bump that
+// balloons them should be a deliberate decision, not a silent one.
+const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
function collectTextAssets(dir) {
const files = [];
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
const files = collectTextAssets(publicRoot);
const failures = [];
+// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
+// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
+// the content hash of everything the worker loads, or a deploy can pair a new
+// worker with a stale cached core/vendor file (static assets are cached 1y).
+const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
+const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
+const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
+const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
+
+if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
+ const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
+ if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
+ failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
+ }
+
+ const expectedVersion = createHash('sha256')
+ .update(readFileSync(spreadsheetWorker))
+ .update(readFileSync(spreadsheetCore))
+ .update(readFileSync(spreadsheetVendors[0]))
+ .update(readFileSync(spreadsheetVendors[1]))
+ .digest('hex')
+ .slice(0, 12);
+ const entrySource = readFileSync(spreadsheetEntry, 'utf8');
+ const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
+ if (actualVersion !== expectedVersion) {
+ failures.push(
+ `SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
+ );
+ }
+} else {
+ failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
+}
+
for (const file of files) {
const rel = relative(repoRoot, file);
const data = readFileSync(file);
diff --git a/scripts/generate-whats-new.mjs b/scripts/generate-whats-new.mjs
new file mode 100644
index 00000000..9611d1c3
--- /dev/null
+++ b/scripts/generate-whats-new.mjs
@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+// Writes src/web/public/whats-new.json for the version in package.json from CHANGELOG.md.
+// Runs as part of `npm run version-packages`, so every release carries its own notes and the
+// UI can announce them once per version without anyone remembering to.
+import { readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { buildWhatsNew } from './lib/whats-new.mjs';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
+const changelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8');
+const notes = buildWhatsNew(changelog, version);
+if (notes.highlights.length === 0) {
+ console.warn(`whats-new: no bold highlights found for ${version}; the in-app announcement will be skipped`);
+}
+writeFileSync(resolve(root, 'src/web/public/whats-new.json'), JSON.stringify(notes, null, 2) + '\n');
+console.log(`whats-new: ${version} -> ${notes.highlights.length} highlight(s)`);
diff --git a/scripts/lib/whats-new.mjs b/scripts/lib/whats-new.mjs
new file mode 100644
index 00000000..81d01182
--- /dev/null
+++ b/scripts/lib/whats-new.mjs
@@ -0,0 +1,42 @@
+/**
+ * Pulls the headline of each change out of one version's section of CHANGELOG.md.
+ *
+ * Release notes here lead each change with a bold phrase ("**Tab layouts (#538, optional).** ..."),
+ * so a highlight is that phrase. The Thanks block is credit, not a feature.
+ */
+
+const MAX_HIGHLIGHTS = 8;
+
+/** The text between `## ` and the next `## ` heading, or '' when absent. */
+export function versionSection(changelog, version) {
+ const lines = changelog.split(/\r?\n/);
+ const start = lines.findIndex((l) => l.trim() === `## ${version}`);
+ if (start === -1) return '';
+ let end = lines.findIndex((l, i) => i > start && /^## /.test(l));
+ if (end === -1) end = lines.length;
+ return lines.slice(start + 1, end).join('\n');
+}
+
+// Bold phrases the release notes use as group labels rather than as the headline of a change:
+// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
+const SECTION_LABELS =
+ /^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
+
+/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
+export function extractHighlights(section) {
+ const out = [];
+ for (const line of section.split('\n')) {
+ // `changeset version` prefixes the first line of a committed changeset with its commit
+ // ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
+ const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
+ if (!m) continue;
+ const text = m[1].replace(/[\s.:]+$/, '').trim();
+ if (!text || SECTION_LABELS.test(text)) continue;
+ if (!out.includes(text)) out.push(text);
+ }
+ return out.slice(0, MAX_HIGHLIGHTS);
+}
+
+export function buildWhatsNew(changelog, version) {
+ return { version, highlights: extractHighlights(versionSection(changelog, version)) };
+}
diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs
new file mode 100644
index 00000000..a27282c0
--- /dev/null
+++ b/scripts/npm-release.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+/**
+ * @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
+ *
+ * npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
+ * @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
+ * keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
+ * Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
+ * `files` cannot help: npm-packlist always includes every root `README.*`.
+ *
+ * Renaming the file would break every link to it, so for the length of the publish only it
+ * moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
+ * whatever the publish did. The move happens HERE, inside the publish command, never as a
+ * step before `changesets/action` in release.yml: that action also runs the version path and
+ * commits the working tree into its version PR, which would commit the deletion.
+ *
+ * A previous run killed between the move and the restore leaves the aside copy behind; the
+ * next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
+ *
+ * node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
+ */
+import { existsSync, renameSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const HIDDEN_README = 'README.zh-CN.md';
+export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
+
+/**
+ * Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
+ * when `publish` fails or throws. Returns the exit code `publish` returned.
+ *
+ * @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
+ * @returns {number}
+ */
+export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
+ const original = join(root, HIDDEN_README);
+ const aside = join(root, ASIDE_NAME);
+ if (existsSync(aside) && !existsSync(original)) {
+ renameSync(aside, original);
+ log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
+ }
+ const moved = existsSync(original);
+ if (moved) {
+ renameSync(original, aside);
+ log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
+ }
+ try {
+ return publish();
+ } finally {
+ if (moved) {
+ renameSync(aside, original);
+ log(`npm-release: ${HIDDEN_README} restored`);
+ }
+ }
+}
+
+function runChangesetPublish() {
+ const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
+ if (result.error) {
+ console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
+ return 1;
+ }
+ return result.status ?? 1;
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+ process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
+}
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
index 414ac2e3..05f5e441 100644
--- a/scripts/postinstall.js
+++ b/scripts/postinstall.js
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
}
}
+// ----------------------------------------------------------------------------
+// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
+// src/web/public/vendor/ for dev mode. The build does the same into dist/.
+// ----------------------------------------------------------------------------
+
+if (!isGlobalInstall) {
+ try {
+ execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
+ console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
+ } catch (err) {
+ hasWarnings = true;
+ console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
+ console.log(colors.dim(` ${err.message}`));
+ }
+}
+
// ----------------------------------------------------------------------------
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
diff --git a/scripts/prepare-spreadsheet-assets.mjs b/scripts/prepare-spreadsheet-assets.mjs
new file mode 100644
index 00000000..dafa2a07
--- /dev/null
+++ b/scripts/prepare-spreadsheet-assets.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/**
+ * Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
+ * dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
+ * build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
+ * in package.json, and check-public-assets.mjs hashes the output into
+ * SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
+ * that changes the bytes fails that check until the token is refreshed.
+ * Source-map comments are stripped: the maps are not shipped.
+ */
+
+import { createRequire } from 'node:module';
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+
+const require = createRequire(import.meta.url);
+const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
+const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
+const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
+
+function copyBrowserBundle(source, outputName) {
+ const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
+ if (/sourceMappingURL/.test(content)) {
+ throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
+ }
+ writeFileSync(join(outputDir, outputName), content, 'utf8');
+}
+
+mkdirSync(outputDir, { recursive: true });
+copyBrowserBundle(excelSource, 'exceljs.min.js');
+copyBrowserBundle(fflateSource, 'fflate.min.js');
diff --git a/scripts/sync-plugin.mjs b/scripts/sync-plugin.mjs
index 80963044..25c023e2 100644
--- a/scripts/sync-plugin.mjs
+++ b/scripts/sync-plugin.mjs
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
+// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its
+// directory): the server and its npm package both carry the release version.
+const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json';
const check = process.argv.includes('--check');
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
@@ -78,6 +81,21 @@ for (const file of MANIFESTS) {
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
}
+// 3. The MCP registry manifest.
+{
+ const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8'));
+ const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')];
+ let changed = false;
+ for (const target of targets) {
+ if (target.version !== version) {
+ drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`);
+ target.version = version;
+ changed = true;
+ }
+ }
+ if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n');
+}
+
if (drift.length === 0) {
console.log(`plugin in step: mirror identical, manifests at ${version}`);
} else if (check) {
diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh
index 169535ef..a3777ac9 100755
--- a/scripts/tmux-manager.sh
+++ b/scripts/tmux-manager.sh
@@ -27,6 +27,13 @@ SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
+# Codeman keeps the prefix table on its socket down to send-prefix (browser tabs
+# are attach clients on the same server, src/tmux-prefix-table.ts), so Ctrl+B d
+# does not detach here. An attach claims F1 in the root table instead, like
+# `codeman tui` does, and only when nothing else holds it.
+DETACH_KEY="F1"
+CLAIMED_DETACH_KEY=0
+
# Cached data
CACHED_JSON=""
@@ -36,12 +43,46 @@ REFRESH_INTERVAL=5 # Refresh data every 5 seconds
# Cleanup on exit
cleanup() {
+ release_detach_key
tput cnorm 2>/dev/null
stty echo 2>/dev/null
echo ""
}
trap cleanup EXIT
+# Command bound to $DETACH_KEY in the root table; empty when the key is free
+detach_key_binding() {
+ "${TMUX_CMD[@]}" list-keys -T root 2>/dev/null \
+ | sed -nE "s/^bind-key +(-[^T ][^ ]* +)*-T root ${DETACH_KEY} +//p" | sed -E 's/ +$//'
+}
+
+release_detach_key() {
+ if [[ $CLAIMED_DETACH_KEY -eq 1 ]]; then
+ [[ "$(detach_key_binding)" == "detach-client" ]] && "${TMUX_CMD[@]}" unbind-key -T root "$DETACH_KEY" 2>/dev/null
+ CLAIMED_DETACH_KEY=0
+ fi
+}
+
+# Attach to a session with a key that gets you back out
+attach_session() {
+ local mux_name=$1 bound status
+ bound=$(detach_key_binding)
+ if [[ -z "$bound" ]] && "${TMUX_CMD[@]}" bind-key -T root "$DETACH_KEY" detach-client 2>/dev/null; then
+ CLAIMED_DETACH_KEY=1
+ bound="detach-client"
+ fi
+ if [[ "$bound" == "detach-client" ]]; then
+ echo -e "${CYAN}Attaching... (${DETACH_KEY} to detach)${NC}"
+ else
+ echo -e "${CYAN}Attaching... (${DETACH_KEY} is taken; detach from another shell: tmux -L $CODEMAN_TMUX_SOCKET detach-client -s $mux_name)${NC}"
+ fi
+ sleep 0.3
+ "${TMUX_CMD[@]}" attach-session -t "$mux_name"
+ status=$?
+ release_detach_key
+ return $status
+}
+
# Check dependencies
check_dependencies() {
if ! command -v jq &> /dev/null; then
@@ -352,9 +393,7 @@ interactive_menu() {
if check_alive "$mux_name"; then
tput cnorm
clear
- echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
- sleep 0.3
- "${TMUX_CMD[@]}" attach-session -t "$mux_name"
+ attach_session "$mux_name"
tput civis
need_full_redraw=1
force_refresh
@@ -483,7 +522,11 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach "; exit 1; }
force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName")
- check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
+ if check_alive "$mux_name"; then
+ attach_session "$mux_name"
+ else
+ echo "Session dead or not found"
+ fi
;;
kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill "; exit 1; }
diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md
index c2aac7da..755ade18 100644
--- a/skills/codeman/SKILL.md
+++ b/skills/codeman/SKILL.md
@@ -1,17 +1,26 @@
---
name: codeman
description: >-
- Drive Codeman, the session manager this agent is running inside, over its HTTP API:
- list sessions, start worker sessions, send them prompts, block until they finish
- (wait / wait-output / send-and-wait), 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, watch
- another session, or start and manage workers. Only usable inside a Codeman-managed
- session (CODEMAN_MUX=1); refuse to act otherwise.
+ Drive Codeman, the session manager this agent is running inside, over its HTTP API,
+ from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
+ Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
+ CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
+ 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, 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.
@@ -28,11 +37,44 @@ 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`, `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.
+
+- **Every call fails at once with "couldn't connect" or "connection refused".** Your
+ CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
+ does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
+- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
+ `404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
+ process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
+ the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
@@ -41,13 +83,14 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run).
-**Codeman seeds the preamble file for you** when it spawns a claude session (server
-1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
+**Codeman seeds the preamble file for you** when it spawns your session (claude since
+server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
+usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +118,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
-grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
-# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
+# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
+# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
+# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -85,7 +131,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
-envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
+envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -93,9 +139,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
-# draw the lineage. Set once here and every present and future create call carries it;
-# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
-# fail a spawn, so there is no case where you would want to leave it off.
+# draw the lineage. Set once here and every present and future create call carries it.
+# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
+# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -120,15 +166,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # -> "true"/"false". `shift+tab` is the one token
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -144,7 +190,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -162,7 +208,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
- t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -172,7 +218,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not
- local sid="$1" k i=1
+ local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -196,6 +242,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
+# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
+# it is set Claude Code's advisor tool: a stronger model the worker consults before
+# committing to an approach, on a recurring error and before declaring the task done.
+# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
+# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +258,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
- -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
+ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
- + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
+ # A server without advisor support DROPS the field instead of refusing it, so read it
+ # back: a worker silently missing the advisor it was asked for is worth one line.
+ if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
+ [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
+ echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
+ fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -301,6 +359,16 @@ spawn_workers() {
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
+ # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
+ # the TUI): the server pastes a multi-line prompt as one block, and the loop below,
+ # like the server's own submit check, cannot recognise a pasted block that is still
+ # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
+ # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
+ # joined with a space. For a long multi-line task, write a file and send its path.
+ case "$p" in *$'\n'*|*$'\r'*)
+ p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
+ esac
+ [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -361,7 +429,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
- t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
+ t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -372,10 +440,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
-CODEMAN_PREAMBLE=1.30.1
+CODEMAN_PREAMBLE=1.44.0
PREAMBLE
)
-. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
+. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -426,7 +494,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -493,6 +561,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
+- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
+ (`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
+ stronger model it consults before committing to an approach, on a recurring error and
+ before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
+ below the worker's own model is never attached (on an Opus worker only `opus` and
+ `fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
@@ -508,8 +582,9 @@ That is true of no other non-claude mode, and it is worth knowing why: the DeepS
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
-what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
-`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
+what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
+`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
+no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
@@ -542,11 +617,12 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
| resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) |
-| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
+| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) |
| watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) |
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
+| leave a message a session reads when it is ready, any mode, multi-line | `codeman agent post "…"` (`POST .../inbox`). Nothing is typed into its pane and no turn starts; the receiver reads with `codeman agent inbox --wait` and removes with `codeman agent ack `. An idle receiver never looks on its own: `codeman agent ls` shows unread mail, then `send` it one line | [§5.15](reference/verbs.md#515-agent-inbox) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -621,8 +697,9 @@ You are yourself a session on this server, and the API has **no undo**.
user's repository state, so say that you did; **removing** one discards any
uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)).
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
-- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user
- cap of 25 that fires the same 409). Case creation is uncapped and writes real
+- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and,
+ in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires
+ the same 409. Case creation is uncapped and writes real
directories. Clean up every session you start, and never retry `quick-start` in a
loop.
@@ -649,6 +726,7 @@ these**; open the one row you actually hit.
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
+| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
## 6. Setup and auth
diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh
index 11982e34..52f4707d 100644
--- a/skills/codeman/preamble.sh
+++ b/skills/codeman/preamble.sh
@@ -1,4 +1,7 @@
-# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
+# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
+# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
+# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
-envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
+envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -15,9 +18,9 @@ fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
-# draw the lineage. Set once here and every present and future create call carries it;
-# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
-# fail a spawn, so there is no case where you would want to leave it off.
+# draw the lineage. Set once here and every present and future create call carries it.
+# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
+# it. It can never fail a spawn, so there is no case where you would want to leave it off.
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # -> "true"/"false". `shift+tab` is the one token
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
- --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
+ --data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -66,7 +69,7 @@ _trust_key() { # -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
- "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -84,7 +87,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
- t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
+ t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -94,7 +97,7 @@ _composer_text() { # -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not
- local sid="$1" k i=1
+ local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -118,6 +121,11 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
+# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
+# it is set Claude Code's advisor tool: a stronger model the worker consults before
+# committing to an approach, on a recurring error and before declaring the task done.
+# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
+# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +137,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
- -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
+ -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
- + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ + (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ + (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
+ # A server without advisor support DROPS the field instead of refusing it, so read it
+ # back: a worker silently missing the advisor it was asked for is worth one line.
+ if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
+ [ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
+ echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
+ fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -223,6 +238,16 @@ spawn_workers() {
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i
+ # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
+ # the TUI): the server pastes a multi-line prompt as one block, and the loop below,
+ # like the server's own submit check, cannot recognise a pasted block that is still
+ # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
+ # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
+ # joined with a space. For a long multi-line task, write a file and send its path.
+ case "$p" in *$'\n'*|*$'\r'*)
+ p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
+ esac
+ [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; }
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -283,7 +308,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
- t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
+ t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -294,4 +319,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
-CODEMAN_PREAMBLE=1.30.1
+CODEMAN_PREAMBLE=1.44.0
diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md
index d8b26507..8a41cacf 100644
--- a/skills/codeman/reference/endpoints.md
+++ b/skills/codeman/reference/endpoints.md
@@ -35,8 +35,13 @@ only and can never drive a session.
with a parse error and `.errorCode` is simply absent (see
[symptom 6](#6-jq-parse-error-instead-of-an-errorcode)). Ten failed attempts from one
IP then get a plain-text `429 Too Many Requests` with `Retry-After`, decaying over 15
-minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). **Never retry a
-failing credential in a loop**: you will lock the address out of the login path for
+minutes (`AUTH_FAILURE_MAX` = 10, `AUTH_FAILURE_WINDOW_MS` = 15 min). In multi-user
+mode the per-IP cap is 50 (`AUTH_IP_FAILURE_MAX`) and each account allows 10 failed
+guesses per IP; both are checked before the password, so past either even a correct
+password from that address gets the 429 (for the per-account one, only for that
+account). From the 10th failed guess on, an account's wrong guesses also get the 429
+from every address, but a correct password still logs in. **Never retry a failing
+credential in a loop**: you will lock the address out of the login path for
everything, including the user's browser through a tunnel (tunneled traffic arrives as
127.0.0.1, so one bucket covers it all).
@@ -113,7 +118,7 @@ Every JSON response: `{"success":true,"data":…}` or
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) |
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
| `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host |
-| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
+| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which |
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help |
@@ -237,7 +242,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
-`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
+`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -341,17 +346,24 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
-, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
+, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
+A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
+model id): Claude Code's advisor tool, a stronger model the worker consults before
+committing to an approach, on a recurring error and before declaring the task done. It is
+a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
+(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
+`CODEMAN_WORKER_ADVISOR` is set.
+
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
-`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
-Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
-`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
+`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
+Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
+`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -360,7 +372,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob
is absent, `jq -r` prints the literal string `null`, and every later call then targets
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap:
-the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
+the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap),
`NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`,
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
loop.
@@ -384,17 +396,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
-`envOverrides`). Three differences that break copied code:
+`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
+differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
- (`session-routes.ts:878` returns `{ session: lightState }`).
+ (the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
- `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
- (`session-routes.ts:648`).
+ `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default)
+ (`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
@@ -412,8 +425,9 @@ callers send no body at all.
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out),
the number-one silent failure.
-- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
- a dialog), send `{"input":"\r"}`.
+- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste
+ and the `\r` submits all lines (without `useMux`, multi-line input is written straight
+ into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`.
- `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one
is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000
character body passes validation and *then* 400s at the route against
@@ -478,7 +492,7 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
- `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
+ `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper:
@@ -599,6 +613,22 @@ Approving a permission dialog on your own is exactly the laundering this skill f
can access, which includes the user's own working sessions. An approval belonging to one
of those is something you **report**, never something you answer.
+### Agent inbox: hand a worker text without typing into its pane
+
+Stored, not typed: the receiver reads it when it asks. Semantics, CLI form and the
+receiver loop: `verbs.md` §5.15.
+
+| Task | Call |
+|------|------|
+| post to a session's inbox | `POST /api/v1/sessions/:id/inbox` body `{"text":"…"}` → `.data.message.{id,from}`; 409 = inbox full, nothing stored |
+| read (yours marks seen, anyone else's is a peek) | `GET /api/v1/sessions/:id/inbox` → `.data.{receiver,pending,unseen,messages[]}` |
+| block until something unseen arrives | `GET /api/v1/sessions/:id/inbox?wait=60000` → `.data.wait.{posted,timedOut,ended}` |
+| remove what you handled (receiver only, idempotent) | `POST /api/v1/sessions/:id/inbox/ack` body `{"ids":["…"]}`; 403 from anyone else |
+| counts per session, never text | `GET /api/v1/agent-inbox/summary` → `.data.sessions[id].{pending,unseen,oldestUnseenAt,parked}` |
+
+`from` and "who is the receiver" both come from the `X-Codeman-Parent-Session` header the
+preamble's `CURL` sends, never from the body.
+
### The wait primitives
Three bounded long-polls. Shared semantics:
@@ -626,8 +656,8 @@ Three bounded long-polls. Shared semantics:
|--------|---------|---------------|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
-| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
-| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only |
+| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
+| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
@@ -788,11 +818,11 @@ response echoes.
| `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright |
| `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` |
| wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line |
-| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start |
-| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message |
+| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start |
+| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message |
| SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab |
| active bash tools tracked | 20 per session | oldest entries drop off `active-tools` |
-| auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
+| auth failures per IP | 10, decaying over 15 min (multi-user: 50 per IP, plus 10 per account per IP) | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential |
Case creation is **uncapped**, which is the one place restraint has to come from you:
every `quick-start` with a new `caseName` creates a real directory on the user's disk.
diff --git a/skills/codeman/reference/history.md b/skills/codeman/reference/history.md
new file mode 100644
index 00000000..ae087fb6
--- /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 00000000..8f454748
--- /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 2d7d68d0..99db822e 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) |
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
-| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
+| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a
the worker's messages arrive tagged `from-name=""`; a derived-name worker's
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
-peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
-is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
+peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose
+is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), because `--name` is also the conversation's
`/resume` title and terminal title and suppresses Claude's own generated title. A
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
-and an auto name are NOT passed, so such a worker's peer name is derived; use
+and an auto name are NOT passed (with `pinCliSessionName` off, the default), so such a worker's peer name is derived; use
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
only unsafe characters is dropped), and the docker/remote builders never see it at all
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
-Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
+Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md
index af814653..c03bb194 100644
--- a/skills/codeman/reference/recipes.md
+++ b/skills/codeman/reference/recipes.md
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
-[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
+[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
-# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
+# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -391,10 +391,10 @@ the worker remembering to print a token.
## Flow 5: watch for a worker stuck on a prompt
-Claude workers can block on a permission dialog. `blocked` is a wait signal
-(claude-mode only, and it needs Codeman's hooks in the worker's directory: see Flow 7
-step 4), so watch for it and surface the question to the user instead of guessing an
-answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
+Claude workers can block on a permission dialog. `blocked` is a wait signal (a claude
+worker needs Codeman's hooks in its directory for it: see Flow 7 step 4; a codex worker
+gets it from its screen, see below), so watch for it and surface the question to the user
+instead of guessing an answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
one (the same setting that decides whether the readiness marker in Flow 1 ever
appears):
@@ -409,10 +409,13 @@ if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
fi
```
-Where the worker has no hooks, `blocked` never fires and a stuck worker looks exactly
-like a slow one: your marker wait burns its whole bound. The fallback is the same
-terminal tail, taken when a bound runs out, and the same rule about not answering it
-yourself.
+Codex workers are the one exception without hooks: Codeman reads their approval,
+question and trust dialogs off the screen, so the same watch works with
+`until=blocked,exit` (codex has no `stop`, a `400`), and the dialog is answered in the
+terminal, never through the Approvals Inbox. Anywhere else without hooks, `blocked` never
+fires and a stuck worker looks exactly like a slow one: your marker wait burns its whole
+bound. The fallback is the same terminal tail, taken when a bound runs out, and the same
+rule about not answering it yourself.
## Flow 6: claude fan-out over messaging
diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md
index 46eecc8d..d72d46bc 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
@@ -95,11 +95,15 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
+ one that does not answer or cannot be read (an unreachable network mount, a
+ permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
+ create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
-`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the
-per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
+`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via
+`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap,
+25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a
remote or docker host named by the case no longer exists), `OPERATION_FAILED` and
`INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on
`.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r`
@@ -300,10 +304,16 @@ duplicate resend costs nothing.
Codeman types the text and sends Enter **only when the input contains a carriage
return**; without it your command sits unsubmitted on the worker's prompt and
everything downstream times out. No response field catches this: `delivered:true`
-means "written to the pane", **not** "submitted". Newlines are stripped, so input is
-single-line by construction. Build the body with `jq -n` for any prompt you did not
-author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on
-the first double quote, backslash or `$` in a real prompt:
+means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the
+text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line,
+though:** its Enter loop and the server's submit check cannot reliably recognise a
+multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in
+Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space,
+which loses their layout; put long multi-line content in a file
+([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n`
+for any prompt you did not author as a literal, because the inline
+`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$`
+in a real prompt:
```bash
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
@@ -373,8 +383,10 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
-`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
-400, and lifecycle transitions there are coarse (a short shell command may emit **no**
+`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
+400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
+question or trust dialog is up; a wait for it answers at once if one already is), and
+lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
@@ -425,7 +437,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
-no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
+no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -511,7 +523,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
-`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
+`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
@@ -538,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.
@@ -582,9 +596,10 @@ allowlist in §4.
### 5.9 Big input via the workspace
-The composer is a single line capped at 65536 characters with newlines stripped, which
-makes it a bad channel for a spec, a diff or a file list. The workspace is the good
-one, and for a local or docker case you are on the same filesystem as the worker.
+The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed
+as submitted, which makes it a bad channel for a spec, a diff or a file list. The
+workspace is the good one, and for a local or docker case you are on the same
+filesystem as the worker.
1. Write `TASK.md` into the worker's workspace with your own file tools. The path is
`.data.casePath` from `quick-start`, or the `workingDir` you passed to
@@ -594,10 +609,10 @@ one, and for a local or docker case you are on the same filesystem as the worker
3. Wait on `DONE_` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)),
then read `RESULT.json` back with your own tools.
-This sidesteps the byte cap, the newline stripping and the quoting hazards in one
-move, and it makes the marker **split by construction**: the token lives in the file,
-never in the line you type, so the echo of your own keystrokes cannot match it. The
-worker also gets to re-read the task instead of holding it in one echoed line.
+This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards
+in one move, and it makes the marker **split by construction**: the token lives in the
+file, never in the line you type, so the echo of your own keystrokes cannot match it.
+The worker also gets to re-read the task instead of holding it in one echoed line.
⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose
filesystem you cannot see, and any worker **currently editing** the directory you are
@@ -694,7 +709,7 @@ The shape, each step verified live (probes, failure modes and safety detail in
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
`sessionName` in quick-start to pick it (a `w-` placeholder-shaped name is not
- pinned, so it lists derived); older setups list a name derived from the case folder.
+ pinned by default, so it lists derived); older setups list a name derived from the case folder.
No row = messaging is off for that worker (it is feature-flagged even on matching
CLI versions, observed live): fall back to the HTTP recipes without complaint.
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
@@ -751,3 +766,63 @@ Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unifie
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
+### 5.15 Agent inbox
+
+Every session has an inbox. A message posted there is stored, not typed: the receiver's
+pane is untouched, no turn starts, and the text may span several lines. The receiver
+reads when it is ready. Use it for results a worker hands back, for a follow-up task a
+busy worker should pick up after its current one, and for anything longer than one
+composer line. Use `send` when you want a turn to start now.
+
+```bash
+codeman agent post "$WORKER" "$(cat task.md)" # ONE quoted argument; prints pending/unseen
+codeman agent inbox --wait --timeout 300000 # your own inbox; blocks while nothing is new
+codeman agent ack […] # remove what you handled, by the full id inbox printed
+codeman agent inbox "$WORKER" # peek at someone else's: marks nothing
+codeman agent ls # INBOX column: pending (new), parked
+```
+
+The same over HTTP: `POST /api/v1/sessions/:id/inbox {"text":…}`, `GET
+/api/v1/sessions/:id/inbox?wait=`, `POST /api/v1/sessions/:id/inbox/ack {"ids":[…]}`,
+`GET /api/v1/agent-inbox/summary`. The preamble's `CURL` already sends
+`X-Codeman-Parent-Session`, which is how the server knows who you are.
+
+Rules that matter:
+
+- **Reading removes nothing.** Every read returns every pending message until you ack
+ it, so a crash between reading and doing loses nothing. Ack once the work is done,
+ not when you have read it: a sender looking at `ls` sees unacked mail as not done.
+- **Only the receiver's own read counts.** Your read of your own inbox marks messages
+ seen (`seen: true` on the next read); reading another session's inbox is a peek and
+ changes nothing for it. Only the receiver can ack (403 otherwise).
+- **`--wait` waits for something new.** It returns at once when there is unseen mail
+ and otherwise blocks until a post arrives; seen but unacked messages come back with it
+ and do not end the wait. Timeout is exit 2, and so is a wait the server cut short
+ (a restart): loop again. Exit 3 means the session list no longer has the session.
+ `--wait` on someone else's inbox returns at once while they have unseen mail (only
+ their read clears it); check `ls` instead.
+- **The sender is resolved, not claimed.** `from` is the session the header resolves to,
+ or `null` (the user). In single-user mode that is a hint, since every local process
+ holds the same credentials. In multi-user mode it is checked against the owner;
+ an admin can still act as any session of the receiver's owner.
+- **Full means refused.** 200 messages or 512 KiB per inbox, 16 KiB per message. A full
+ inbox answers 409 and stores nothing; the oldest is never dropped. Messages expire 12
+ hours after they were posted.
+- **Nobody is woken.** An idle receiver does not look at its inbox by itself, and the
+ server never types into its pane. When `ls` shows a worker idle with unread mail and
+ 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 00000000..1ff2f280
--- /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 00000000..eec6c159
--- /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 00000000..f79e1234
--- /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/attachment-registry.ts b/src/attachment-registry.ts
index 261fda67..d64e7ce3 100644
--- a/src/attachment-registry.ts
+++ b/src/attachment-registry.ts
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
-import { basename, extname, isAbsolute } from 'node:path';
+import { homedir } from 'node:os';
+import { basename, extname, isAbsolute, join } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
@@ -53,15 +54,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet = new Set([
*/
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet = EDITABLE_EXTENSIONS;
+/**
+ * Document types an attachment card previews. Also the list `codeman attach`'s
+ * error text names, so the help cannot drift from what is accepted. `xlsx` is
+ * previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
+ */
+export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
+
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
- 'pdf',
- 'docx',
- 'pptx',
+ ...DOCUMENT_ATTACHMENT_EXTENSIONS,
'md',
'txt',
...VIDEO_ATTACHMENT_EXTENSIONS,
@@ -143,6 +149,11 @@ class AttachmentRegistry {
export const attachmentRegistry = new AttachmentRegistry();
+/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
+export function supportedAttachmentExtensions(): string[] {
+ return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
+}
+
export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
}
@@ -154,6 +165,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
+ if (normalized === 'xlsx') return 'spreadsheet';
if (normalized === 'md') return 'markdown';
// Everything else in the text family reads as text, including code and
// config: the card and the preview both treat it as a plain-text file.
@@ -324,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string,
options: RegisterExternalAttachmentOptions = {}
): Promise {
- if (!requestedPath || !isAbsolute(requestedPath)) {
+ // `~/…` is the home of the session's HOST: expanded here for a local session,
+ // and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
+ // whose home this server does not know. A clicked terminal link sends it as is
+ // (Claude Code echoes attachments as `~/…`). Every guard below runs on the
+ // resolved absolute path either way.
+ const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
+ if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
- : resolveLocalAttachment(requestedPath));
+ : resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else.
diff --git a/src/case-rename.ts b/src/case-rename.ts
new file mode 100644
index 00000000..e8b46012
--- /dev/null
+++ b/src/case-rename.ts
@@ -0,0 +1,226 @@
+/**
+ * @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
+ *
+ * A case folder is held in more places than an open file. A running agent has it as its working
+ * directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
+ * by the folder's path (`/projects/`), so a moved
+ * folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
+ * open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
+ * that which are not route logic:
+ *
+ * - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
+ * folder while it is being moved, and two renames from overlapping;
+ * - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a
+ * rename refuses to move a folder under, since it can neither list nor stop them;
+ * - the move itself, retried a few times for a transient lock and turned into a message that says
+ * what to do when it is a real one;
+ * - carrying Claude Code's history for the folder over to the new path.
+ *
+ * Pure of the server: every function takes the paths it works on.
+ *
+ * @module case-rename
+ */
+
+import fs from 'node:fs/promises';
+import { dirname, join, resolve, sep } from 'node:path';
+import { claudeProjectsDir } from './utils/claude-transcript.js';
+
+export type CaseRenameErrorCode =
+ | 'INVALID' // a name that is not allowed, or the same name
+ | 'EXISTS' // the new name or folder is already taken
+ | 'BUSY' // another rename (or a session being stopped) holds the folder
+ | 'LOCKED' // the OS refused the move: something still has the folder open
+ | 'CROSS_DEVICE' // the move would cross filesystems
+ | 'UNSUPPORTED'; // remote and Docker cases
+
+export class CaseRenameError extends Error {
+ constructor(
+ readonly code: CaseRenameErrorCode,
+ message: string
+ ) {
+ super(message);
+ this.name = 'CaseRenameError';
+ }
+}
+
+const locked = new Set();
+
+const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
+
+/** True when `path` is, or is inside, a folder that is being renamed right now. */
+export function isCasePathLocked(path: string): boolean {
+ const target = resolve(path);
+ for (const held of locked) {
+ if (within(target, held)) return true;
+ }
+ return false;
+}
+
+/**
+ * Sessions constructed but not yet in the server's session map, where the rename route looks for the
+ * sessions it must list or stop. A create awaits a few things between `new Session` and that map (the
+ * owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the
+ * session, moved the folder, and left it to start in a path that no longer existed.
+ */
+const starting = new Map