Merge origin/master into #644: keep diff|patch beside #621's tsv and zip

Master's #621 added tsv and zip to both file-link extension groups in
constants.js while this branch added diff|patch to the same lines. The
resolution keeps both, in master's order with diff|patch after sql where the
PR put it; the rooted and relative groups stay identical, and every upload
document extension plus .diff/.patch still links in both patterns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:12:35 +02:00
commit 14a9e66547
159 files changed
+10409 -591

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.41.1",
"version": "1.42.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+35
View File
@@ -1,5 +1,40 @@
# aicodeman
## 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 `<case>/.claude/worktrees/<name>` on a new branch `worktree-<name>` 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
+17 -13
View File
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| 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/<file>.test.ts` · see Testing for the excluded suites |
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.41.1 (must match `package.json`)
**Version**: 1.42.0 (must match `package.json`)
## Project Overview
@@ -124,9 +124,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Key Patterns
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
@@ -138,17 +138,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The workspace-trust dialog's `❯` (on `No, exit`) arms the check too, so the same capture holds idle off while `isTrustDialogScreen()` matches inside `TRUST_DIALOG_WINDOW_MS`: an agent that waited for idle and prompted the dialog confirmed `No, exit` and killed the pane. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A codex dialog is BLOCKED, read off the screen, and only ever an ALERT.** Codex has no hook for its approval, question and trust dialogs, so `capabilities.workDetect.blockedLine` names their chrome and `web/screen-blocked.ts` raises what a `permission_prompt` hook raises, as an `answerInTerminal` item. ⚠️ Never key a keystroke off that match (the agent writes most of the pane), and never hand its item a `capture` (the inbox's sweep is Claude-shaped; the session's 2 s re-read owns the clear). A blocked screen beats the working line. → [architecture-invariants#screen-read-blocked-dialogs-codex](docs/architecture-invariants.md#screen-read-blocked-dialogs-codex)
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output, and a throttled read is DEFERRED onto that timer, never dropped (the dialog's paint is often the pane's last output); a stopped session never scans. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
**Process-tree walks are bounded** (`proc-tree.ts`, pure): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by one cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: async, in-flight-shared, and ANY error discards the result rather than caching a truncated `ps`). ⚠️ Never walk a process tree with per-node `pgrep` or unbounded recursion (the unbounded version took a machine down): the walk must terminate on cycles, cap depth (`PROC_WALK_MAX_DEPTH`) and node count (`PROC_WALK_MAX_NODES`), and never spawn anything. ⚠️ Keep it in its own module so the test exercises the shipped code, and report truncation through `onTruncated` naming both caps, never silently. → [architecture-invariants#process-tree-walks-are-bounded](docs/architecture-invariants.md#process-tree-walks-are-bounded)
@@ -168,7 +170,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
@@ -192,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory, claude-only queue fed by `/api/hook-event`, at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's and codex's screen-read ones are alert-only `answerInTerminal`), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
@@ -200,7 +202,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph-and-pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
@@ -213,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode. A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
@@ -227,15 +229,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
@@ -289,7 +293,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate.
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
+3 -3
View File
@@ -274,10 +274,10 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 4. Talk to the agent
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
- **Paste, drop or attach images and videos** directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
- **Paste, drop or attach images, videos and documents** (PDF, Office files, zip and plain text) directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view, including `~/` and relative paths and paths broken across lines. An `.html` report opens as a real page, Markdown opens rendered, and a folder opens as a grid of its files.
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues the prompts waiting on you from Claude Code, Codex and DeepSeek Harness sessions in one list. Claude prompts are answerable from the header bell, Claude dialogs also from the phone home screen, and a Claude permission prompt also from a push notification's Approve/Deny buttons; Codex and DeepSeek items are alerts that tell you to answer in the terminal, and prompts from the other CLIs do not enter it. 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. A program that copies with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard too.
### 5. Make it autonomous
@@ -857,7 +857,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
7. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Those two come from hooks (Claude Code's own, and the DeepSeek Harness status bridge); `shell` and the other external CLIs (opencode/codex/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
7. **Only `claude` and `deepseek` sessions emit `stop`; `codex` emits `blocked` too.** Those come from hooks (Claude Code's own, and the DeepSeek Harness status bridge), except codex's `blocked`, which Codeman reads off its screen while an approval, question or trust dialog is up (answer it in the terminal). `shell` and the other external CLIs (opencode/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`, and codex adds `blocked`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes
+3 -3
View File
@@ -276,10 +276,10 @@ codeman service install # systemd/launchd 服务:重启后自动回
### 4. 与智能体对话
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
- **粘贴、拖放或附加图片和视频**,直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **粘贴、拖放或附加图片、视频和文档**(PDF、Office 文件、zip 和纯文本),直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把 Claude Code、Codex 和 DeepSeek Harness 会话里等着你的提示排成一个队列。Claude 的提示可以在页头的铃铛里直接作答,Claude 的对话框也可以在手机首页作答,Claude 的权限请求还可以用推送通知上的 Approve/Deny 按钮作答;Codex 和 DeepSeek 的条目只是提醒,会告诉你去终端里作答;其他 CLI 的提示不会进入收件箱。🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
### 5. 让它自主运行
@@ -859,7 +859,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop` 与 `blocked`。** 这两个来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接);`shell` 与其他外部 CLI(opencode/codex/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop`;`codex` 还会发出 `blocked`。** 它们来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接),codex 的 `blocked` 除外:批准、提问或信任对话框出现时,Codeman 从它的屏幕上读出这个信号(请在终端里回答)。`shell` 与其他外部 CLI(opencode/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`,codex 另外接受 `blocked`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
### 常用配方
+2 -2
View File
@@ -238,8 +238,8 @@
},
"install": {
"command": {
"linux": "npm install -g @deepseek-ai/dsh",
"darwin": "npm install -g @deepseek-ai/dsh"
"linux": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)",
"darwin": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)"
},
"npmPackage": "@deepseek-ai/dsh",
"docsUrl": "https://github.com/deepseek-ai/deepseek-harness",
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.41.1",
"version": "1.42.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.41.1",
"version": "1.42.0",
"transport": {
"type": "stdio"
},
+1
View File
@@ -19,6 +19,7 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
+130 -27
View File
@@ -78,10 +78,15 @@ the HTTP status.
|-------------|------|---------|
| `INVALID_INPUT` | 400 | Malformed request / failed validation |
| `UNAUTHORIZED` | 401 | Authentication required or failed |
| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) |
| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else |
| `NOT_FOUND` | 404 | Resource does not exist |
| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name |
| `SESSION_BUSY` | 409 | Session is busy |
| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) |
| `ALREADY_EXISTS` | 409 | Resource already exists |
| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists |
| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin |
| `OPERATION_FAILED` | 422 | Well-formed but could not be completed |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
@@ -205,15 +210,19 @@ auto-retried by several clients (silently doubling the polling load), `504` is w
a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` /
`limitPaused`. Reserve error handling for the four codes in the table below.
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
CLIs (`opencode`, `codex`, `gemini`, `antigravity`, `pi`) render their own TUIs and post
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
accepted, and of those only `exit` is dependable: see the caveats under
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
**explicitly** on such a session is a
`400`; omitting `until` never fails, the server just drops them from the default set
and echoes the narrowed set back as `wait.until`. Three more places hooks can go
**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for
`codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell`
runs no agent, and the external CLIs render their own TUIs and post no hooks
(`deepseek`'s status bridge reports the same two, see its own docs). The one exception
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and
`working` are screen-checked for `opencode`, `gemini`, `pi` and `omp` (as for `claude` and
`codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before
building on `idle`.
Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on
`codex` included); omitting `until` never fails, the server just drops them from the
default set and echoes the narrowed set back as `wait.until`. Three more places hooks can go
missing even in `claude` mode: a **Docker case** needs
`CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound
Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH
@@ -237,10 +246,10 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
| Signal | Source | Actually fires for |
|--------|--------|--------------------|
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook | `claude` only, and rarer than it looks: see below |
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`). `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`antigravity`, `grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi` and `omp` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below |
| `exit` | no process is behind the session | every mode |
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
@@ -250,9 +259,9 @@ the wait promptly instead of burning the caller's whole timeout on something tha
can no longer happen). On a `claude` worker, prefer an explicit `until=stop,exit`
once the session is up: the default set's `idle` also resolves on a spinner pause,
and on a fresh session the **startup** `idle` (emitted when the CLI first comes up)
can land inside your first wait window and report a turn that never ran. Measured:
a session parked on the trust dialog emits no *further* `idle`, so it is the
startup transition, not the dialog, that produces the false success below.
can land inside your first wait window and report a turn that never ran. That
startup `idle` is held while Claude's workspace-trust dialog is on screen (inside
the 90 s startup window), because the dialog's own ❯ is not a composer.
⚠️ **`exit` means "nothing is running", which includes "not started yet".** The
server answers from `pid === null` plus a mux-layer pane-death probe, and that
@@ -277,6 +286,17 @@ asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on
long turn, but a worker that never comes back is far more likely to be working than
blocked, and polling `blocked` alone will sit at its timeout.
**`blocked` on `codex` is read off the screen**, because codex has no hooks and is
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set.
A codex pane showing a command, edit or network approval, a question (`request_user_input`)
or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude
`permission_prompt` raises, alert-only: answer it in the terminal), and the session reads
`idle` while it is up. Unlike a hook, this is a level the server keeps reading, so a wait
for `blocked` that arrives while the dialog is already up answers at once
(`immediate: true`); a wait that did not ask for `blocked` is unaffected. It clears 2 to 4 s
after the dialog leaves the screen. `stop` stays a `400` on codex: nothing on a screen says a
turn ended.
⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell
session emits its one `idle` at startup and then stays `status: "idle"` forever,
whatever the pane is doing, so it never emits a *transition*. Since send-and-wait
@@ -290,11 +310,11 @@ instead. The same caution applies to the external CLIs.
Nothing here reports "the agent is ready for a prompt", and no combination of
`until`/`fresh` synthesizes one. A freshly created session reads as `exit` (above),
and a `claude` worker in a brand-new case comes up on the CLI's **trust dialog**,
which contains a ❯ prompt of its own. Send-and-wait posted at that moment types the
prompt into the dialog, where the `\r` never gets past it, while the session's
startup `idle` lands inside the wait window: the wait resolves on `idle` in a
couple of seconds with `timedOut: false`, which looks exactly like a completed
turn.
which contains a ❯ of its own. A prompt posted at that moment is typed into the
dialog, and its `\r` confirms the highlighted `No, exit`: the CLI quits and the
pane dies, so send-and-wait resolves on `exit`. Codeman holds the session's `idle`
while the dialog is on screen, so waiting for `idle` before prompting no longer
clears too early, but that still does not make `idle` a readiness signal.
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
@@ -520,7 +540,7 @@ read the timeout as "the worker is wedged" and kill a session that was working f
| `errorCode` | HTTP | When |
|-------------|------|------|
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that installs no hooks (the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
| `SESSION_BUSY` | 409 | this session's waiter cap is full |
| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem |
@@ -600,6 +620,21 @@ beside the 30 per minute the image path keeps; without the flag the route is
byte-identical to the image-only version, same 50 MB image cap and same 30/min
bucket. The response shape is unchanged (`path`, `filename`).
Beyond images, the route also accepts documents a user hands an agent to read,
admitted by the name's extension (the Content-Type a phone attaches to a `.md`
or `.csv` is unreliable): the office formats `.pdf`, `.docx`, `.pptx`, `.xlsx`,
`.zip` and the text family `.txt`, `.md`, `.csv`, `.tsv`, `.json`, `.log`,
`.yaml`, `.yml`, `.xml`. A document is buffered under the same 50 MB image cap,
its bytes are checked against the extension (a magic-number signature, or UTF-8
with no NUL byte for text) and answered 415 on a mismatch, and it is saved under
a sanitized stem of its name, so `Q3 report.pdf` is findable as
`paste-<ms>-<hex>-Q3-report.pdf`. A named type that is neither an image nor a
document (a script, an HTML page, a `.sh`) is refused 400 `Unsupported file
type`. That refusal is a scope choice, not a security property: `file-raw`
already serves non-media text inert (`text/plain`, `nosniff`), so this just
keeps the server from storing what it would serve or an agent could run, and
html and svg are the ones deliberately kept out.
## Rendered HTML files
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
@@ -744,15 +779,18 @@ non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are
in-memory (a server restart drops them; the next prompt re-fires the hook).
AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode
sessions only; DeepSeek Harness (its status bridge) and Codex (dialogs read off its
screen) open **alert-only** items with `answerInTerminal: true`. Items are
in-memory (a server restart drops them; the next prompt re-fires the hook, and a
codex dialog still on screen is read again after the pane re-attaches).
Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
- `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first,
ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId,
sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?,
toolSummary?, message?, cwd?, context?, options?: {n, label}[],
acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame;
acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame;
`options` is present only when the dialog's numbered choices parsed
confidently; `acknowledgedAt` marks an item a human has already looked at
(see `/viewed` below) and tells clients not to re-arm its tab alert. Listing
@@ -766,8 +804,9 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
(sends the digit; accepted only when `n` is among the item's parsed
`options`), or `{ action: 'text', text }` (idle prompts only; submits the
line as a prompt). `404 NOT_FOUND` when the item is no longer pending,
`409 CONFLICT` when the dialog left the screen or another actor answered
first, `422 OPERATION_FAILED` when the session refused input.
`400 INVALID_INPUT` for an `answerInTerminal` item (`<CLI> approvals must be
answered in the terminal`), `409 CONFLICT` when the dialog left the screen or
another actor answered first, `422 OPERATION_FAILED` when the session refused input.
- `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes.
- `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId,
acknowledged: itemId | null }`. Marks the session's pending **idle** item as
@@ -1011,6 +1050,70 @@ Both routes accept two optional query parameters, which the UI sends from its pe
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
## Git worktrees of a case
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
`GET /api/cases/:name/worktrees` lists `git worktree list` for the case's repository:
```json
{
"caseName": "myrepo",
"repoRoot": "/home/me/codeman-cases/myrepo",
"worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees",
"truncated": false,
"worktrees": [
{ "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false,
"locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] },
{ "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login",
"main": false, "isCase": false, "managed": true, "name": "fix-login",
"sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] }
]
}
```
`managed` is `true` only for `<repoRoot>/.claude/worktrees/<name>`: those are the ones Codeman starts sessions in and removes; any other worktree of the repository is listed for information. `sessionCount` counts live sessions of every owner running in that worktree (a session belongs to the deepest worktree holding its folder), `sessions` lists the ones the caller may see. `lockReason`/`prunableReason` appear when git reports them.
`POST /api/cases/:name/worktrees` with `{ name, branch?, base? }` creates one (`201`, `{ worktree: { name, path, branch, base, head, excludeAdded, warnings } }`):
- `name`: 1 to 64 characters of `A-Z a-z 0-9 . _ -`, starting with a letter or digit, no `..`, not ending in `.` or `.lock`. `400` otherwise.
- `branch`: the NEW branch, default `worktree-<name>`. Letters, digits, `.`, `_`, `-`, `/`, then `git check-ref-format --branch` must accept it unchanged. An existing branch is `409 ALREADY_EXISTS` (never reset), and so is a name whose folder already exists.
- `base`: where the branch starts, default `HEAD` (the case's current commit): a branch, tag or commit, resolved before anything is written (`400` when it is not a commit, which includes an empty repository).
- `excludeAdded` lists the lines appended to the repository's local `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git did not already ignore them: `/.claude/worktrees/`, so the main checkout does not show the worktrees as untracked, `/.claude/settings.local.json`, the hooks file Codeman writes for every claude session, which would otherwise make `git worktree remove` refuse, and the agent skill copy (`/.claude/skills/codeman/`, `/.agents/skills/codeman/`: every skills folder the CLI registry names) that sessions get while `agentSkillEnabled` is on, for the same reason.
- A symlinked `.claude` or `.claude/worktrees` directory is refused (`400`): a repository cannot redirect the worktree outside itself.
`DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true]` removes a managed worktree with `git worktree remove` **without `--force`**: git refuses a worktree with modified or untracked files (or a locked one), and that refusal is the answer, `409 CONFLICT` with git's own words in `error`. A live session at or inside the worktree is `409` as well. With `deleteBranch=true` the branch is deleted with `git branch -d`, never `-D`, so an unmerged branch is kept: the response is still `200`, with `branchDeleted: false` and git's reason in `branchError`. Response: `{ removed: { name, path, branch, branchDeleted, branchError? } }`. A folder git does not list as a worktree of this repository, or a symlink in its place, is `404`/`400`. A worktree that is, sits inside, or holds a Docker case workspace is `400` too (git would run inside a folder the container writes, and the removal would delete that workspace under it). A managed worktree whose folder is gone (deleted by hand; the list reports it `prunable`) is still removable: git then only drops its record, which is what frees the name for a new worktree. A session never starts in one.
**Starting a session in a worktree** goes through the normal create paths: `POST /api/quick-start` takes `worktree: "<name>"` next to `caseName` and runs the session (and the hooks, the agent skill and `modelOverride`) in the worktree folder; the response adds `worktree` and `workingDir`. It is refused for remote and Docker cases (`400`), for a worktree that is not a registered, managed one (`404`), and for a case that does not exist (`404`: a worktree launch never scaffolds a case). `POST /api/sessions` takes the worktree path as `workingDir` like any other folder.
## Settings
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected.
| Method | Path | Body | Notes |
| ------ | ----------------------------- | ------------------------------------ | ----- |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). |
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. |
| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
### System operations
The same multi-user rule covers the operations that act on the whole server. For a non-admin:
| Method | Path | Multi-user non-admin |
| ------ | ----------------------------- | -------------------- |
| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) |
| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) |
| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) |
| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) |
| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session |
| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins |
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
+1 -1
View File
@@ -11,7 +11,7 @@ One cross-session inbox for every prompt that is waiting on a human: permission
## Scope
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating.
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating. Since then, DeepSeek Harness (through its status bridge) and Codex (through dialogs read off its screen) open alert-only items; answerable items are still Claude's alone (see `src/web/approval-inbox.ts`).
- Permission prompts occur for sessions running `ClaudeMode` `normal` / `auto` / `allowedTools` (and the trust-folder dialog even under skip-permissions). Question and idle prompts occur in every mode including `dangerously-skip-permissions`.
- In-memory store (plus the frontend seeding from it on load). Server restart drops items; hooks re-fire on the next prompt. No new state file in v1.
+35 -7
View File
@@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
### Input delivery and WS resilience
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
@@ -141,7 +141,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
**Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `tesLine truncated
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant.
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
@@ -270,6 +270,8 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
@@ -278,6 +280,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ A fourth, optional `workDetect` field, `awaitingLine`, makes a turn that ENDED waiting for workers the CLI started count as WORKING: with background agents or an ultracode workflow still running at turn end, Claude closes the turn with `✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when they report back, so the pane is quiet with the composer up and every other signal read it as idle (reported 2026-09-28 on a live 2.1.283 session). `_probePaneWorking()` ORs it into the same capture. ⚠️ It must never be searched across the pane like `workingLine`: Claude renders the row from a `useState` snapshot taken at turn end and never redraws it, so the words stay on screen after the workers finish and a pane-wide match would pin the tab busy until they scroll off. `isAwaitingWorkers()` (pure, `session-activity.ts`) finds the composer (the LAST row carrying `promptGlyph`, bare or boxed), walks up at most `AWAITING_SEARCH_ROWS` past blank, box-drawn and indented rows, and tests only the first column-0 row. ⚠️ Keep the pattern anchored on column 0 (`^✻`): Claude's own rows start there and the agent's prose never does, which is what stops an agent from holding its own session busy. It is deliberately NOT tested on the PTY stream, where redraws of the stale row would re-mark working. Tests: `test/session-awaiting-workers.test.ts`.
⚠️ A fifth, optional `workDetect` field, `blockedLine` (plus `blockedLines`), marks a pane showing a dialog the CLI is blocked on, and it WINS over the working line in the same capture (codex's question dialog carries `esc to interrupt`, its working line, in its own key hints). See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
### The watching signal (a quiet pane that is not waiting for you)
**A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen.
@@ -286,7 +290,17 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ **A chip that waits on a HUMAN must never count as watching**: Claude's Artifact comment monitor (an agent that published a page and hears nothing until somebody comments) is refused for the whole row by a `^`-anchored negative lookahead keyed on "Artifact" alone, so a footer cut off mid-chip is refused too, and the `^` is what stops the engine retrying past the chip and reporting a shell beside it.
**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both — its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'` — no hook event from a codex session reaches `notePrompt()`, so a forged label costs a wrong badge and nothing else, and a CLI that gains hook signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces).
**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both: its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'`: no IDLE prompt from a codex session reaches `notePrompt()` (its screen-read dialogs open PERMISSION items, which a watching label never acknowledges), so a forged label costs a wrong badge and nothing else, and a CLI that gains idle signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces).
### Screen-read blocked dialogs (codex)
**Codex is the one CLI Codeman launches with its approvals ON by default, and it has no hook to say it stopped for one** (`hooks: 'none'`), so a codex session parked on "Would you like to run the following command?" read as plainly idle, and one parked on a question as WORKING. `capabilities.workDetect.blockedLine` (optional, per CLI, plus `blockedLines`, 1 to 24, default 1) names the dialog's chrome; `_probePaneWorking()` reads it off the same capture the idle confirmation takes (`_paneShowsBlocked()` → `isBlockedScreen()`, pure, `session-activity.ts`: the last `blockedLines` non-blank rows joined with `\n`, tested once, so a pattern can demand several markers and end on `$`), and `Session` emits `blockedChanged`. `session-listener-wiring.ts` hands that to `web/screen-blocked.ts`, which raises EXACTLY what a Claude `permission_prompt` hook raises (`sessionWaits.notifySignal('blocked')`, an Approvals Inbox item, the `hook:permission_prompt` broadcast with `source: 'screen'`, push and webhook, a run-summary row), so the tab alert, phone overview, TUI and every other consumer needed no second code path; clearing resolves the item it opened, and `approval:resolved` clears every client.
⚠️ **ALERT ONLY, never a keystroke.** The text is pane-derived and the agent writes most of the pane, so the pattern must end on chrome only the CLI draws at the FOOT of the screen, where a dialog replaces the composer (codex's: a `› N.` option row, then its key-hint row as the last row, allowing for that row to wrap; a transcript that quotes every word still has codex's column-0 composer row below it). The item is `answerInTerminal` (#610's shape), the answer route refuses it, push carries no `approvalId`, and Codeman never answers such a dialog itself, so a forged match costs a false alert and nothing more. The message every surface shows is built from the CLI's registry label, never from the screen (a question's text and a command preview are the agent's to choose).
⚠️ **Raise on one reading, clear on two, and keep reading while it is up.** The raise needs no timer: a dialog is drawn at the END of a burst of output, so the settle probe (pane quiet, then capture) sees it. The clear does: answering resumes the turn, a busy pane never reaches the settle path again until the turn ends, and the red alert outlived the dialog by minutes, so `_blockedRecheckTimer` re-reads the screen every `BLOCKED_RECHECK_MS` (2 s, one `capture-pane`, only while a dialog is up; cleared in `_clearAllTimers()`). ⚠️ That read is a synchronous exec (about 8 ms of blocked event loop, measured 2026-10-14) and a dialog can sit unanswered overnight, so the timer SKIPS it while the pane is quiet: a dialog leaves the screen only through a repaint, which reaches the session as PTY output, so nothing drawn since the last reading that saw the dialog (`_lastActivityAt < _blockedSeenAt`) means it is still up; a safety-net read still runs every `BLOCKED_QUIET_RECHECK_MS` (30 s). Never once a reading has MISSED the dialog (`_blockedGoneReads > 0`): tmux updates the pane before it sends this client the repaint, and the confirming read must not wait for output. It clears after `BLOCKED_CLEAR_READS` (2) readable captures in a row without the dialog, because one capture can land inside a full-frame repaint, and a clear followed by a re-raise sends the alert, push and webhook twice for one dialog. An unreadable capture (`null`) changes nothing in either direction. A new pane (`_resetBuffers()`) and a PTY exit clear it, announced, while the listeners are still attached. The working probe decides blocked-over-working from THIS capture, never from `_screenBlocked`, which lags a vanished dialog by design.
⚠️ **Hooks win.** `raiseScreenBlocked()` does nothing for a session whose CLI passes `hooksAvailableForMode()`, so a CLI that declared both could never alert twice for one dialog; and the inbox item gets no `capture` callback (only the frame, as context), because the inbox's option parser and staleness sweep are shaped for Claude's dialogs and the SESSION owns this item's lifetime. ⚠️ Codex's `Implement this plan?` after a plan turn is deliberately not matched: its `enter select · esc back` footer is the same as `/model`, `/permissions` and `/review`, menus the user opened at the keyboard, and the plan turn has ended there. Measured fixtures (codex-cli 0.162.0, 2026-10-14, widths 200 to 36): `test/fixtures/codex-dialogs/`. Tests: `test/codex-blocked-detection.test.ts` (the pattern and the session state), `test/screen-blocked.test.ts` (what the server raises), the codex blocks of `test/routes/session-wait-routes.test.ts` and `test/routes/approval-routes.test.ts`.
### Workspace-trust dialog auto-accept
@@ -300,7 +314,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago.
⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). ⚠️ For the same reason a read the `TRUST_DIALOG_RETRY_MS` throttle refuses is DEFERRED onto that timer, never dropped: the dialog's own paint is often the last output a launching pane produces and lands inside the throttle of the read the blank screen before it triggered, so dropping it left 2 of 12 fresh launches on Claude Code 2.1.294 sitting on the dialog with no keystroke ever sent. And a stopped session never scans: killing the pane produces output, the buffer tail still holds the dialog, and a deleted session went on scanning it and arming the timer for several seconds.
### Owner tab layouts
@@ -448,6 +462,20 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
### Git worktrees of a case
**Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `<repo>/.claude/worktrees/<name>` on a new branch (default `worktree-<name>`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-<name>`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`).
- ⚠️ **Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). A managed worktree whose folder is GONE stays removable (`resolveManagedWorktree({ allowMissing })`, removal only, never a session start: git lists it at exactly the managed path, no component on the way is a symlink, and `git worktree remove` then only drops git's record), since otherwise its name could never be used again.
- ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code. A managed worktree that is, sits inside, or holds a Docker case workspace (a Docker case linked to the worktree folder) is never removed (400): `git worktree remove` runs a status inside the tree and then deletes it, ignored files included, under the container.
- ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`).
- ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `<common git dir>/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). The agent skill copy (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) gets a line too, for every skills folder the registry names (`capabilities.agentSkillDir`, passed in by the route as data). A symlinked `info` dir or `exclude` file is reported, never written through.
- **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify <base>^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository.
- **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w<n>-<worktree>`.
- The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record.
Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`.
### Ultracode and workflow-run visualization
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
@@ -615,7 +643,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).
@@ -683,7 +711,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Command palette and shortcut registry
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ Every xterm key handler (Pane A in terminal-ui.js, Pane B in terminal-tile.js) must call `isUserBoundShortcutEvent(ev)`, which is true only for a chord the user REBOUND a generically dispatched action to: the document capture handler's `preventDefault()` does not stop xterm, so a rebind to e.g. Alt+J would otherwise also type `ESC j` into the PTY. Defaults are deliberately not gated (Ctrl+L must reach the CLI for its redraw), so the gate also skips an entry re-captured onto its own default, `toggleSessionSidebar` (it has its own gate, `shouldToggleSessionSidebarFromShortcut`) and copy-selection. Capturing, re-enabling and Reset all go through `findShortcutConflict`/`findShortcutBindingConflict` (settings-ui.js); Alt+1-9, Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved there.
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
@@ -1096,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### Buffers, uploads, and terminal history
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
+64 -7
View File
@@ -46,9 +46,10 @@ interface CliEntry {
launch: CliLaunch; // the structured argv template
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
// — how this CLI's pane shows work, work it started in the background, and a turn
// that ended waiting for workers it will resume from
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
// blockedLine?, blockedLines? }
// (how this CLI's pane shows work, work it started in the background, a turn
// that ended waiting for workers it will resume from, and a dialog it is blocked on)
// .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
@@ -62,7 +63,7 @@ interface CliEntry {
### Regexes that come from config
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
@@ -118,9 +119,65 @@ nothing the agent can write — not even the status line above it, whose command
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
shares its slot with the last row of the transcript whenever no terminal is running, so a
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
sure its row is one the agent cannot write.
IDLE prompt from a codex session ever reaches the approvals inbox (its screen-read dialogs,
below, open PERMISSION items, which a watching label never acknowledges), so a forged label
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
pattern, make sure its row is one the agent cannot write.
### Dialogs a CLI blocks on (`blockedLine`)
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
report it. Codex is the reason it exists: it is the one CLI Codeman launches with its
approvals on by default, and with `hooks: 'none'` a codex session parked on "Would you like to
run the following command?" used to read as plainly idle, and one parked on a question as
WORKING (the question's own key hints end in `esc to interrupt`, codex's working line). A
pane matching it is BLOCKED, which raises exactly what a Claude `permission_prompt` hook
raises: the red "needs you" tab alert and desktop notification, web push and webhooks, the
phone overview's NEEDS YOU row, an Approvals Inbox item, a run-summary row and the `blocked`
wait signal (`web/screen-blocked.ts`). `stop` stays unavailable for such a CLI: nothing on a
screen says a turn ended.
The pattern runs over the last `blockedLines` (1 to 24, default 1) non-blank rows, joined
with newlines and tested once (`isBlockedScreen()`, `session-activity.ts`), so it can demand
several markers and anchor the dialog's footer on the END of the window (no `m` flag: `$` is
the last row). Codex's needs a `› N.` option row and then its key-hint row (`Press enter to
confirm or esc to cancel`, `tab to add notes | enter to submit answer | esc to interrupt`,
`enter continue · esc quit` for the startup trust prompt, `esc back` when it follows the
update prompt), allowing for that row to wrap onto rows of its own below about 60 columns.
Measured on codex-cli 0.162.0 at widths 200 down to 36: the command, edit and
network-escalation approvals, single and multi-question
`request_user_input` dialogs (notes field open or closed), and the workspace trust prompt
match; the idle and working views, popups the user opened (`/model`, `/permissions`,
`/review`), the composer holding the dialogs' words and a transcript quoting them verbatim
do not. `Implement this plan?` after a plan turn is deliberately not matched: its footer is
the same as `/model`'s, and the turn has ended there. The fixtures are in
`test/fixtures/codex-dialogs/`.
Three rules hold for any CLI that declares one:
- ⚠️ The text is pane-derived and the agent writes most of the pane, so the pattern must end
on chrome only the CLI draws, at the foot of the screen, where a dialog replaces the
composer. A match is an ALERT and nothing else: the inbox item is `answerInTerminal`, the
answer route refuses it, push offers no Approve/Deny, and Codeman never answers such a
dialog itself, so a forged match costs a false alert and never a keystroke.
- The state raises on one reading (the session's settle probe, which reads the screen once
the pane goes quiet, which is when a dialog has just been drawn), and while it is up a timer
re-reads the screen every 2 s (`BLOCKED_RECHECK_MS`), because answering resumes the turn and
a busy pane would not reach the settle path again until the turn ended. The read is skipped
while the pane has drawn nothing since the last reading that saw the dialog (a dialog leaves
only through a repaint), with a safety-net read every 30 s (`BLOCKED_QUIET_RECHECK_MS`), so a
dialog left unanswered for hours does not cost a synchronous `tmux capture-pane` every 2 s.
It clears only after
two readable captures in a row without the dialog (`BLOCKED_CLEAR_READS`), so a capture
taken mid-repaint cannot clear and re-raise it (a second alert, push and webhook for one
dialog). An unreadable capture changes nothing in either direction. A blocked screen wins
over the working line in the same capture.
- Hooks win: a session whose CLI reports blocked itself (`hooksAvailableForMode()`) gets
nothing from the screen, so declaring both can never alert twice for one dialog.
A `GET /api/sessions/:id/wait?until=blocked` that arrives while such a dialog is already up is
answered at once (`immediate: true`): unlike a hook's edge, this is a level the server is
still reading. A caller that asked only for `idle` keeps getting `idle`.
### Three capabilities that must stay independent
+1 -1
View File
@@ -204,7 +204,7 @@ trailing carriage return:
| Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. |
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
+2 -2
View File
@@ -265,8 +265,8 @@ siblings; no other external CLI mode qualifies. Two edges are worth repeating he
reports a turn that never ran, and leaves the prompt in a pane that was not yet
accepting input. Wait for the composer (`❯`) instead.
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
which for every external CLI is inferred from output stabilization; a dsh TUI
that repaints rarely reads as idle mid-turn.
which for dsh is inferred from output stabilization; a dsh TUI that repaints
rarely reads as idle mid-turn.
## 6. The web UI as a tab
+12 -9
View File
@@ -220,17 +220,18 @@ whether your integration works, and the last one is what actually bites:
to die in the field.
- **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by
default). Read it rather than assuming you got what you asked for.
- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session
even `idle` fires only once at startup, so send-and-wait there can only time out.
- **`stop` only exists for `claude` (and `deepseek`) sessions, `blocked` also for
`codex`** (read off its screen), and on a `shell` session even `idle` fires only once
at startup, so send-and-wait there can only time out.
See the Gotchas below.
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
like success.** A session reports `idle` before its CLI has spawned, and a `claude`
worker in a brand-new case comes up on the CLI's **trust dialog**, which has a ❯
prompt of its own. Prompt it at that moment and the text lands in the dialog, the
`\r` does not get past it, and the session's startup `idle` lands inside the wait
window: the wait resolves on `idle` in a couple of seconds with `timedOut: false`,
indistinguishable from a finished turn. Wait for the pid, then wait for the
prompt of its own. Prompt it at that moment and the text lands in the dialog,
whose highlighted option is `No, exit`, so the prompt's `\r` quits the CLI. Codeman
holds the session's `idle` while the dialog is on screen, which makes waiting for
`idle` safe but still not a readiness signal. Wait for the pid, then wait for the
composer, answering the dialog only as the bounded fallback.
⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options'
@@ -438,9 +439,11 @@ Every one of these has cost somebody real time.
a multi-word match is unreliable there. Match one short space-free token, ideally
one you printed yourself, and keep it out of the typed line (your own keystrokes
echo into the stream).
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini`,
`antigravity` or `pi` sessions.** They come from Claude Code hooks, which no other mode
installs, so only `idle`, `working` and `exit` exist there. Asking for them
- **`stop` and `blocked` never fire for `shell`, `opencode`, `gemini`, `antigravity` or
`pi` sessions, and `stop` never for `codex`.** They come from Claude Code hooks, which
no other mode installs, so only `idle`, `working` and `exit` exist there. The exception
is `blocked` on `codex`, which Codeman raises from the approval, question and trust
dialogs it reads off codex's screen. Asking for them
explicitly is a `400`; omitting `until` is safe, since the server drops them from
the default set and echoes what it actually waited on as `wait.until`. Even in
`claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to
+6 -2
View File
@@ -121,8 +121,12 @@ the remote host, `grok login --device-auth` exists for exactly this.
## Known gaps
- **No idle/completion hook yet.** Idle detection falls back to output-stabilization
like the other external CLIs. Grok has a hooks system, so a Codeman hook POSTing to
- **No idle/completion hook yet.** The registry entry declares no `workDetect` prompt
marker or working line, which is what gives Codex, OpenCode, Gemini, Pi and OMP a
screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after
that it has only generic cues: Claude's `❯` marker, which grok does not print (see the
cron item below), and a braille spinner glyph. Working and idle are unreliable for
grok, as for Antigravity. Grok has a hooks system, so a Codeman hook POSTing to
`/api/hook-event` is the highest-value follow-up.
- **No response viewer.** Grok writes ACP JSONL sessions under
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
+2 -2
View File
@@ -161,8 +161,8 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
## 7. SSE Event Filtering
+1 -1
View File
@@ -244,7 +244,7 @@ RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignme
session.sendInput(task.prompt)
│
▼
writeViaMux() → tmux send-keys -l "prompt..." + Enter
writeViaMux() → tmux paste-buffer -p (prompt as one bracketed paste) + Enter
│
▼
Claude CLI receives prompt, executes, outputs results
+1 -1
View File
@@ -19,7 +19,7 @@ When user says "COM":
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
+5 -1
View File
@@ -90,7 +90,7 @@ Groups are always in this order, and a session is in exactly one of them:
| Group | Glyph | Means | Comes from |
| --- | --- | --- | --- |
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed) |
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed; for Codex, a dialog read off its screen) |
| | `✋` | Waiting for your next instruction, or errored | `idle_prompt`, or an errored session (equally something only a human clears) |
| **WORKING** | `✻` animating | A turn is running | The same working classification the web dashboard uses |
| **IDLE** | `○` | Live, but sitting there | |
@@ -158,6 +158,10 @@ the TUI says `that dialog is no longer on screen` rather than pressing a key int
live composer. The answer is scoped to the options the server parsed off the actual
frame, never to a guess.
Only a Claude dialog can be answered this way. A Codex or DeepSeek Harness item is an
alert: the server refuses the answer and the TUI shows its message saying to answer in
the terminal, so attach with `Enter` and answer it in the pane.
An idle prompt (`✋`) is not a dialog: there is nothing to approve, so `p` is the
reply path and the footer says `p reply` instead of `p prompt`.
+16 -4
View File
@@ -69,15 +69,15 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Antigravity and Grok: generic cues only, unreliable |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek yes; others no |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
Everything that makes a session a session works everywhere. What is Claude-only is mostly
@@ -153,6 +153,17 @@ Two behaviours that are deliberate and worth knowing:
does; before 1.26.1 every Codex session reported idle for its whole life. Codex
conversations also appear in Past Sessions and can be resumed, and on phones the keyboard
bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack.
- **A dialog waiting for you turns the tab red.** Codex is launched with its approvals on
unless you tick the bypass option, and it has no hooks to say it stopped, so Codeman reads
its dialogs off the screen: a command, edit or network approval, a question it asks you
(Plan mode), and the "Trust this folder?" prompt when it starts in a new folder. Each one
raises the same red "needs you" alert a Claude permission prompt does, with the desktop
notification, web push, webhook, the phone overview's NEEDS YOU row and an Approvals Inbox
card, and the alert clears a few seconds after you answer. Answer in the terminal: Codeman
never answers a Codex dialog for you, so the inbox card and the push carry no Approve or
Deny buttons. While such a dialog is up the session shows as idle (a question used to show
as working), and agents can wait on it with the `blocked` signal. "Implement this plan?"
at the end of a plan is not counted, since the turn has ended there.
### Gemini
@@ -294,7 +305,8 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
hook-driven features.
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
see [Codex](#codex) above).
- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
non-Claude mode that reports idle, working and blocked to Codeman itself.
- **Pi** if you want a fast, unsandboxed agent and you understand what project trust does.
+6 -3
View File
@@ -46,7 +46,8 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an
event use.
Several sessions can share one case. That is the normal way to parallelize: three workers
in the same repo, three tabs, one case.
in the same repo, three tabs, one case. They then also share one folder; when the workers
edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees).
A session carries state the case does not:
@@ -163,9 +164,11 @@ report events back: a permission prompt appeared, the turn finished, the agent w
task completed. Those events drive tab alerts, the Approvals Inbox, notifications, and the
wait primitives.
This is why some features are Claude-only. The one partial exception is DeepSeek Harness,
This is why some features are Claude-only. One partial exception is DeepSeek Harness,
whose terminal front door reports idle, working and blocked to Codeman over the harness's
own supervisor contract, so it gets the hook-driven signals without a hook file. The other
own supervisor contract, so it gets the hook-driven signals without a hook file. Codex gets
one of them from its screen: Codeman reads its approval, question and trust dialogs off the
pane and raises the same red "needs you" alert a `permission_prompt` hook raises. The other
CLIs have no equivalent, so for them Codeman falls back to watching terminal output, which
is coarser: it can see that something happened, not what it was.
+10 -7
View File
@@ -141,17 +141,20 @@ Read these before writing any code. Each one has cost somebody an afternoon.
5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer
`200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because
tunnels cut idle connections.
6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from
Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and
the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
explicitly there is a `400`, while omitting `until` is always safe. On a shell session
6. **Only `claude` and `deepseek` sessions emit `stop`; `blocked` also fires for `codex`.**
Claude's come from Claude Code hooks, DeepSeek's from the harness reporting its state to
Codeman, and Codex's `blocked` from Codeman reading its approval, question and trust
dialogs off the screen (a wait that asks for `blocked` while such a dialog is already up
answers at once). Shell and the other external CLIs accept only `idle`, `working`, and
`exit`; asking for `stop` explicitly there (codex included) is a `400`, while omitting
`until` is always safe. On a shell session
`idle` fires **once at startup and never again**, so synchronize hook-less sessions with an
output marker instead.
7. **Nothing reports "ready", so wait for it explicitly.** A new session answers
`{"signal":"exit","immediate":true}` until its PID exists, and that means *not started*,
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog; prompt
it there and the wait resolves on idle in about two seconds looking exactly like a finished
turn, while your text sits stuck in the dialog.
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog, whose
highlighted option is `No, exit`: prompt it there and your Enter quits the CLI. Codeman
holds `idle` while the dialog is on screen, but that is not a readiness signal either.
### Recipes
+73
View File
@@ -0,0 +1,73 @@
# Git Worktrees
Sessions in one case share its folder. That is fine for one agent, but three agents in the
same checkout edit the same files, overwrite each other's builds, and commit each other's
half-finished work. A **git worktree** gives each of them a checkout of its own: same
repository, same history, a separate folder on a separate branch.
Codeman creates worktrees for a case whose folder is a git repository, starts sessions in
them, and removes them again when the work is done.
## Where they live
`<case>/.claude/worktrees/<name>`, on a new branch `worktree-<name>`. That is exactly where
Claude Code's own `claude --worktree <name>` puts them, so a worktree made in Codeman is one
Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager
lists mark a session that runs in one with a `⑂ <name>` pill.
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
never touched.
## Creating one and starting a session in it
1. Pick the case in the toolbar's case picker.
2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's
**Worktrees** button).
3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters).
4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run
mode).
5. **Create & start session**.
The worktree is created from the case's current commit, and the session opens in it as a tab
named `w1-<name>`. Under **Branch and start point** you can name the branch yourself or start
from another branch, tag or commit. An existing branch is never reused or reset: pick another
name.
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
## Removing one
Two ways, and both use `git worktree remove` **without** `--force`:
- **Remove…** on the worktree's row in the dialog.
- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove
worktree**, which kills the session and then removes its worktree.
git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman
shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the
work first, then remove again. A worktree a live session still runs in is refused too.
**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose
commits are not merged yet. The worktree is still removed, and the message says the branch was
kept.
If a worktree's folder was deleted by hand, its row says **missing** and offers only
**Remove…**: that clears the record git keeps of it, so the name can be used for a new worktree.
## What is not supported
- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating
a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is
never run there. A remote case's repository is on another machine. For the same reason a
worktree that a Docker case uses as its workspace (or that holds one) is never removed from
here: remove the Docker case first.
- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or
an empty repository with no commit yet.
- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but
Codeman neither starts sessions in them from the dialog nor removes them.
The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`).
+5 -4
View File
@@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain:
| ------------------- | -------- | --------------------------------------------------- |
| System | 56 | Status, settings, digest, updates, tunnel. |
| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
| Cases | 34 | Create, link, clone, remote and docker cases. |
| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. |
| Files | 17 | Preview, edit, raw, attachments, path picker. |
| Orchestrator | 10 | Plans and phases. |
| Ralph | 9 | Loop control and configuration. |
@@ -142,9 +142,10 @@ Three semantics that break callers who assume otherwise:
`wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex
means no catastrophic backtracking on attacker-influenced output.
Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude
Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other
external CLI sessions accept `idle`, `working`, and `exit`.
Only `claude` and `deepseek` sessions emit `stop`: Claude's come from Claude Code hooks,
DeepSeek's from the harness reporting its state to Codeman. `blocked` fires for those two
and for `codex`, whose approval, question and trust dialogs Codeman reads off the screen.
Shell and the other external CLI sessions accept `idle`, `working`, and `exit`.
## SSE
+1
View File
@@ -67,6 +67,7 @@ codeman web # then open http://localhost:3000
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
| [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? |
+6 -3
View File
@@ -19,11 +19,14 @@ terminal into something that can notify you.
| `teammate_idle` | An agent-team member goes idle. | Team surfaces. |
| `task_completed` | A task finishes. | Task tracking, run summary. |
This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
This is why several Codeman features are Claude-only. One partial exception is DeepSeek
Harness, whose terminal front door reports idle, working and blocked to Codeman over the
harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which
reveals that something happened but not what it was.
file. Codex gets one of them from its screen: Codeman reads its approval, question and
trust dialogs off the pane and raises the same red "needs you" alert a `permission_prompt`
hook raises (see [Agent CLIs](Agent-CLIs#codex)). The other CLIs have no equivalent, so for
them Codeman watches terminal output, which reveals that something happened but not what it
was.
### How hooks get installed
+13 -8
View File
@@ -27,12 +27,15 @@ keystroke echo. Idle now lands a few seconds after a turn genuinely ends.
There are several layers stacked on that: a completion message from the CLI, an AI check,
output silence, and token stability.
**For the other CLIs** it depends on what the CLI tells Codeman. Codex declares its own
prompt glyph and working line, so it gets the same screen check Claude does (before 1.26.1
every Codex session reported idle for its whole life). DeepSeek Harness reports idle,
working and blocked to Codeman itself, which is as precise as hooks. Everything else is
output stabilization: the session is idle when output stops changing. Coarser, and it is
why the features further down this page are Claude-only.
**For the other CLIs** it depends on what the CLI tells Codeman. Codex, OpenCode, Gemini
CLI, Pi and OMP each declare their own prompt marker and working line, so they get the same
screen check Claude does (before 1.26.1 every Codex session reported idle for its whole
life). DeepSeek Harness reports its end of turn and its dialogs to Codeman itself, which is
as precise as hooks; its working and idle display still comes from its output. Antigravity
and Grok Build declare neither. Codeman calls a new pane of theirs idle 3 seconds after
launch, and after that it has only generic cues to go on (Claude's `❯` prompt marker, a
braille spinner glyph), which these CLIs may not draw, so their working and idle status is
unreliable.
## The Respawn Controller
@@ -161,8 +164,10 @@ run summary and lifecycle log carry the detail.
- **The update prompt is what makes a reset survivable.** After `/clear`, everything the
agent knows comes from that summary and the project files. A vague update prompt produces
a vague next cycle.
- **Non-Claude sessions can respawn**, but with output-based idle detection and no
usage-limit auto-resume.
- **Non-Claude sessions can respawn**, but with no usage-limit auto-resume, and with
output-based idle detection except on DeepSeek Harness, whose own end-of-turn report
reaches the respawn controller the way Claude's `stop` hook does while its status bridge
is on (a local session, by default).
- **Do not run respawn on a session you are actively typing in.** It will send prompts
underneath you.
+12 -2
View File
@@ -67,6 +67,13 @@ default. See [Tile Grid](Tile-Grid).
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
rebind is stored as an override on top of the default rather than replacing the table.
Previous Session (`Alt+[`) and Next Session (alternate, `Alt+]`) are rows like any other,
so they can be rebound too.
A chord another action already uses is refused, and the message names that action. The same
check runs when you re-enable a disabled shortcut and when you press Reset: if its chord was
given to something else in the meantime, it stays as it was. `Alt+1` to `Alt+9`,
`Shift+Enter`/`Ctrl+Enter` and `Ctrl+V`/`Cmd+V` are reserved.
Two things are deliberately not rebindable:
@@ -79,8 +86,11 @@ Two things are deliberately not rebindable:
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
swallowed at the terminal layer, or xterm writes the control byte into the session as well
as triggering the action. If you rebind something to a chord the terminal cares about
(`Ctrl+D`, say), expect the CLI to see it too.
as triggering the action. A chord **you** rebind an action to is kept out of the terminal
too, so rebinding Previous Session to `Alt+J` switches the session and sends nothing to the
CLI. The built-in defaults are deliberately not held back this way: `Ctrl+L` clears the
screen and also reaches the CLI, which redraws on it. Binding an action back onto its own
default behaves like the default.
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
+19 -13
View File
@@ -43,8 +43,9 @@ as the keyboard appearing.
Tapping the "C" logo gives a session overview rather than a welcome page:
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips so you can
resolve them without opening the session.
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips (Approvals Inbox
on) so you can resolve a Claude dialog without opening the session. A Codex or DeepSeek
Harness row offers **Answer in terminal** instead, which opens the session.
2. **CURRENT SESSIONS** with live status.
3. **PAST SESSIONS**, resumable.
@@ -66,7 +67,8 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
into the draft.
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack.
@@ -103,12 +105,16 @@ into the Run dropdown.
## The Attach button
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
on the way up.
hand the agent a photo, a video or a document. One tap opens the system's sheet (camera,
photo library, or a file from Files or a cloud drive), nothing else: no editor, no keyboard.
The chosen files upload and their paths land on the prompt, so you then tap the terminal,
write the rest of your message and press Enter. Videos (mp4, webm, mov, m4v, ogv)
upload as recorded, up to 1 GB each by default (`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the
server changes it), so a long 4K clip takes a moment on the way up. Documents are PDFs,
Office files (docx, pptx, xlsx), zips and plain-text formats such as txt, md, csv and json;
a document keeps the plain-letters part of its name in the saved file, so the agent and you
can tell the uploads apart. See [Working With Files](Working-With-Files) for where they land and how long they
stay.
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
not bring it back, so the tap on the terminal is how you get it again. That is the system's
@@ -120,7 +126,7 @@ anything you had already typed, so you can write in front of a path, fix a word,
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
so neither surface holds a half of your message. A multi-line message cannot make that trip
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
shows it is waiting. The Compose editor's own Image button remains for the times you are
shows it is waiting. The Compose editor's own Attach button remains for the times you are
already writing in the editor.
## Tapping, links and copying
@@ -162,9 +168,9 @@ phone, and it is the main reason the feature exists.
## Notifications
Push notifications reach you with no tab open, and with the Approvals Inbox on they carry
**Approve** and **Deny** buttons handled by the service worker, so you can unblock an agent
from the lock screen.
Push notifications reach you with no tab open, and with the Approvals Inbox on, those for a
Claude permission prompt carry **Approve** and **Deny** buttons handled by the service
worker, so you can unblock a Claude agent from the lock screen.
Setup in [Notifications And Approvals](Notifications-And-Approvals).
+30
View File
@@ -60,6 +60,36 @@ Admins see everything.
Ownership threads through every list endpoint, the session lookup helper, the WebSocket
layer, and file previews. A user cannot address another user's session even by id.
## Server-wide settings are admin only
There is one set of server settings (`~/.codeman/settings.json`) for everyone: the feature
switches, the run mode, respawn presets, the case order, the Cloudflare tunnel, the default
models. Every user can **read** them, but only an **admin** can change them. For a regular
user the settings write routes answer `403 FORBIDDEN` (see the
[HTTP API](HTTP-API) and [`docs/api-reference.md`](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
A regular user reads them without what is the admin's: the Deepgram key an admin saved for
voice dictation, and the admin's case names (case order, last used case).
In the browser this means:
- **App Settings → Save** still keeps a regular user's per-device settings (skin, fonts,
header buttons, keyboard bar and the rest of the per-device keys) on that device. If they
also changed a server-wide setting, one notice says it can only be changed by an admin,
and the setting keeps the server's value.
- A regular user's run-mode pick and respawn presets are their own: kept in that browser
under their name, not sent to the server, and still theirs on the next visit (over what
an admin set). Their last used case is not recorded server-wide.
- Reordering cases and switching the tunnel on or off are admin actions.
- If the page could not tell yet who is signed in (a slow or failed sign-in check), the
server's refusal is read the same way: the same notice, and nothing more is sent.
The operations that act on the whole server are admin actions too: updating Codeman,
opening the multi-display window, regenerating the tunnel QR code (it clears every user's
pending QR login) and cleaning up stale session state. **Revoke All** signs a regular user
out of their own logins only, and the tunnel panel lists only their own.
Per-user server settings are not part of this version.
## Safer defaults for regular users
Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
+28 -16
View File
@@ -13,7 +13,7 @@ opening the session.
| Desktop notification | Another window on the same machine | Opt-in |
| Push notification | Anywhere, even with no tab open | Opt-in |
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
| Approvals Inbox | One queue across every session | Opt-in |
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
| Phone overview | Phone home screen, NEEDS YOU section | On |
| Away Digest | Afterwards, as a summary | Opt-in |
@@ -34,8 +34,13 @@ reloading the dashboard while a permission dialog is blocking a session does not
with a normal-looking tab.
For Claude sessions, these come from Claude Code's hooks and are precise about *why* the
session stopped; DeepSeek Harness sessions report the same states themselves. For the other
CLIs there are no hooks, so you get the coarser output-based signal.
session stopped; DeepSeek Harness sessions report the same states themselves. Codex has no
hooks, but its dialogs are distinctive enough to read off the screen, so a Codex session
waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and
clears a few seconds after you answer. For the other CLIs there are no hooks and no dialog
reading, so you get only the coarser working and idle state: read off the screen for
OpenCode, Gemini CLI, Pi and OMP, and unreliable for Antigravity and Grok Build, which
declare nothing Codeman can read it from.
## Window title and OS notifications
@@ -109,11 +114,13 @@ Behaviour worth knowing:
## The Approvals Inbox
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
front door reports its prompts to Codeman.**
front door reports its prompts to Codeman, and Codex sessions, whose dialogs Codeman reads
off the screen.** DeepSeek and Codex cards are alerts: they say "Answer in the terminal" and
carry no answer buttons, because Codeman cannot verify a keystroke into those dialogs.
One queue of every prompt currently waiting on a human, across all your sessions, answerable
in place. When you have eight workers running, this is the difference between checking eight
tabs and checking one list.
One queue of the prompts currently waiting on a human, across all your sessions, with the
Claude ones answerable in place. When you have eight workers running, this is the difference
between checking eight tabs and checking one list.
Turn it on in **App Settings**. Surfaces:
@@ -134,17 +141,20 @@ Behaviour worth knowing:
sending, and refuses with a conflict if the dialog is no longer there. Otherwise your
keystroke would land in the composer as stray text.
- **Permission and question items clear only on definitive signals**: the turn ending, the
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. They
do not clear on a heuristic "looks busy again" signal, because that signal is wrong often
enough to lose a real prompt.
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. A
Codex item also clears a few seconds after its dialog leaves the screen, and a DeepSeek
Harness item when the harness reports it is working again. They do not clear on a
heuristic "looks busy again" signal, because that signal is wrong often enough to lose a
real prompt.
- **In memory only.** Restarting the server clears the queue; the prompts themselves are
still sitting in the sessions.
### Approve and Deny from the notification
With the inbox enabled, push notifications carry **Approve** and **Deny** buttons. Those are
handled by the service worker directly, so they work with no tab open: tap Approve on a
locked phone and the agent continues.
With the inbox enabled, push notifications for a Claude permission prompt carry **Approve**
and **Deny** buttons. Those are handled by the service worker directly, so they work with no
tab open: tap Approve on a locked phone and the agent continues. A Codex or DeepSeek Harness
notification carries no Approve or Deny, since those dialogs are answered in the terminal.
With the inbox off, the buttons are stripped from the notification payload entirely rather
than being shown and failing.
@@ -184,8 +194,9 @@ current sessions, then past ones. Rows use the same language as the tab strip: a
when fine, pulsing while working, yellow when waiting for input, red when a question is
pending.
Answer strips let you resolve a prompt straight from the home screen without opening the
session.
With the Approvals Inbox on, answer strips let you resolve a Claude permission or question
dialog straight from the home screen without opening the session. A Codex or DeepSeek
Harness strip offers **Answer in terminal** instead, which opens the session.
## The Away Digest
@@ -214,7 +225,8 @@ from the lock screen.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
- **Approvals need real signals.** They are built on hook events, which Claude emits and
DeepSeek Harness reports itself; the other CLIs do neither.
DeepSeek Harness reports itself, and on Codex's dialogs as its screen shows them; the
other CLIs give neither.
- **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has
since gone away, Codeman declines rather than typing a digit into the composer.
+5 -1
View File
@@ -29,6 +29,10 @@ The practical rule: **appearance and input are per device, behaviour is shared.*
did not follow you, it is in one of the first two rows, and you change it again on that
device.
In [Multi-User Mode](Multi-User-Mode) the server's settings are shared by every user, so
only an **admin** can change them. A regular user's **Save** keeps their per-device settings
as usual and tells them that server-wide settings can only be changed by an admin.
## App Settings
### Updates
@@ -217,7 +221,7 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. |
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
+6 -3
View File
@@ -116,9 +116,12 @@ seconds in. Current versions confirm against the actual screen before believing
### A session is stuck showing busy
For non-Claude CLIs, idle detection is output-based and coarser by necessity: those CLIs
expose no hooks. A session that has genuinely gone quiet will settle. If it never does,
interrupt it (`Ctrl+C` with nothing selected).
For Antigravity and Grok Build the busy and idle status is unreliable: those CLIs expose no
hooks and declare no working line, so after launch Codeman has only generic cues to go on
(Claude's `❯` prompt marker, a braille spinner glyph), which they may not draw. Codex,
OpenCode, Gemini CLI, Pi and OMP get the same screen check Claude does, so a session of
theirs that has genuinely gone quiet will settle. If it never does, interrupt it (`Ctrl+C`
with nothing selected).
### The agent asks about bypass permissions every time
+30 -8
View File
@@ -168,17 +168,39 @@ blocks sensitive trees. In multi-user mode a non-admin gets only their own user
root, because per-user spaces live inside the home directory and a home-directory root would
expose everyone.
## Images into a session
## Files into a session
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
Paste from the clipboard or drag and drop straight onto the terminal. The file is written
where the agent can read it and the reference is inserted into your prompt. On a phone, the
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
on your prompt, where it behaves like text you typed; the Image button inside the Compose
editor does the same into the editor. Dropping a video onto the terminal works the same way.
toolbar's Attach button opens the camera, photo library or files, and puts the path on your
prompt, where it behaves like text you typed; the Attach button inside the Compose editor
does the same into the editor. Dropping a video or a document onto the terminal works the
same way.
HEIC images from an iPhone are converted to JPEG on the way in.
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
Images (png, jpg, gif, webp, bmp, heic) are re-encoded on the device before they go up (a GIF
goes as is, so it keeps its frames), and HEIC from an iPhone is converted to JPEG. Videos (mp4, webm, mov, m4v, ogv) are stored as recorded,
up to 1 GB each by default (`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than
through server memory. Documents are the office formats (pdf, docx, pptx, xlsx, zip) and the
text family (txt, md, csv, tsv, json, log, yaml, yml, xml). Each is checked on the way in,
against its file signature for the binary formats and against its encoding for text (UTF-8,
no NUL bytes), so a script renamed to `.pdf` is refused while a `.txt` can hold any text at
all. Files with script or page extensions (sh, py, js, html) are not accepted, since a prompt
upload is something for the agent to read, not something for Codeman to store and serve. A
document keeps a trimmed copy of its original name, letters, digits, dot, dash and underscore
with anything else turned into a dash, so `Q3 report.pdf` is findable as
`paste-<time>-<id>-Q3-report.pdf`; a name made entirely of other characters saves without
one. Uploads are refused for remote (SSH) sessions, whose working directory is on the other
host.
Uploads land in `.codeman-uploads/` inside the case, next to the code the agent is working
on, because that is the one path a local agent and a Docker case (whose workspace is mounted
at the same absolute path) both see the same way. The first upload writes a `.gitignore` of `*` into `.codeman-uploads/`, so the
directory ignores itself and your repository never picks the files up, while your own ignore file is
left alone; a `.gitignore` you already had there is never overwritten. Files older than seven
days are swept hourly, and the directory goes when the last session of the case is closed (a
session that is only detached keeps it). Earlier releases wrote to `.claude-images/`; that
directory is still swept and cleaned up the same way for one release so a case in use before
the move is not left with stray files.
## Generated artifacts
+1
View File
@@ -15,6 +15,7 @@
- [Agent CLIs](Agent-CLIs)
- [Custom Model Endpoints](Custom-Model-Endpoints)
- [Working With Files](Working-With-Files)
- [Git Worktrees](Git-Worktrees)
- [Input And Voice](Input-And-Voice)
- [Mobile Guide](Mobile-Guide)
- [Keyboard Shortcuts](Keyboard-Shortcuts)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.41.1",
"version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.41.1",
"version": "1.42.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.41.1",
"version": "1.42.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
@@ -39,7 +39,7 @@
"check:public-assets": "node scripts/check-public-assets.mjs",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"version-packages": "changeset version && node scripts/generate-whats-new.mjs && prettier --write src/web/public/whats-new.json && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
+1 -1
View File
@@ -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.41.1",
"version": "1.42.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+21 -17
View File
@@ -70,7 +70,7 @@ 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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -98,8 +98,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.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.42.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
@@ -108,7 +111,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)
@@ -143,15 +146,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "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() { # <sid> <timeoutMs> -> "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
@@ -167,7 +170,7 @@ _trust_key() { # <sid> -> "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 \
@@ -185,7 +188,7 @@ _composer_text() { # <sid> -> 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)
@@ -195,7 +198,7 @@ _composer_text() { # <sid> -> 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() { # <sid> -> 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
@@ -396,7 +399,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
@@ -407,10 +410,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.33.4
CODEMAN_PREAMBLE=1.42.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -461,7 +464,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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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'
@@ -549,8 +552,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` and `omp` 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:
+14 -11
View File
@@ -1,4 +1,7 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.42.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)
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "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() { # <sid> <timeoutMs> -> "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() { # <sid> -> "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() { # <sid> -> 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() { # <sid> -> 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() { # <sid> -> 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
@@ -295,7 +298,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
@@ -306,4 +309,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.33.4
CODEMAN_PREAMBLE=1.42.0
@@ -634,8 +634,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
@@ -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.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -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
@@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an
`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**
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:
+18
View File
@@ -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)`);
+42
View File
@@ -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 `## <version>` 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)) };
}
+21 -17
View File
@@ -70,7 +70,7 @@ 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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -98,8 +98,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.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.42.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
@@ -108,7 +111,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)
@@ -143,15 +146,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "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() { # <sid> <timeoutMs> -> "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
@@ -167,7 +170,7 @@ _trust_key() { # <sid> -> "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 \
@@ -185,7 +188,7 @@ _composer_text() { # <sid> -> 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)
@@ -195,7 +198,7 @@ _composer_text() { # <sid> -> 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() { # <sid> -> 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
@@ -396,7 +399,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
@@ -407,10 +410,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.33.4
CODEMAN_PREAMBLE=1.42.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -461,7 +464,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.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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'
@@ -549,8 +552,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` and `omp` 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:
+14 -11
View File
@@ -1,4 +1,7 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.42.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)
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "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() { # <sid> <timeoutMs> -> "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() { # <sid> -> "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() { # <sid> -> 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() { # <sid> -> 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() { # <sid> -> 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
@@ -295,7 +298,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
@@ -306,4 +309,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.33.4
CODEMAN_PREAMBLE=1.42.0
+2 -2
View File
@@ -634,8 +634,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
+12 -9
View File
@@ -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.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.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
@@ -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
+3 -1
View File
@@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an
`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**
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:
+1 -1
View File
@@ -914,7 +914,7 @@ export function registerAgentCommands(program: Command): Command {
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
+20
View File
@@ -368,6 +368,22 @@ const capabilitiesSchema = z
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the foot of a pane capture every time a session
// settles and on a timer while a dialog is up, and ~/.codeman/clis.json can set it.
blockedLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'blockedLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Wider than `watchingLines` because a dialog's options wrap at narrow widths (codex
// needs 20 at 36 columns). That is safe only for a pattern that ends on the dialog's
// own footer: the rows above it, which the agent may write, can then supply a
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
@@ -378,6 +394,10 @@ const capabilitiesSchema = z
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.refine(
(v) => v.blockedLines === undefined || v.blockedLine !== undefined,
'blockedLines has nothing to bound without a blockedLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+47 -6
View File
@@ -677,10 +677,11 @@ const CODEX: CliEntry = {
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// What contains it is that codex declares `hooks: 'none'`: no IDLE prompt from a codex
// session ever reaches `notePrompt()` (its screen-read dialogs below open PERMISSION
// items, which a watching label never acknowledges), so there is no idle item to
// pre-acknowledge and a forged label costs a wrong badge and nothing else. A CLI that
// gains idle signals must not keep a pattern this soft.
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
// A sub-agent started without waiting outlives the turn just as a terminal does —
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
@@ -689,11 +690,45 @@ const CODEX: CliEntry = {
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
// match until codex pins one.
// Codex is the one CLI Codeman launches with its approvals on by default, and it has no
// hook to say it stopped for one, so its dialogs are read off the screen. Measured on a
// live codex-cli 0.162.0 pane on 2026-10-14 (`-a on-request -s read-only`, widths 200,
// 80, 60, 44 and 36): every dialog it blocks on REPLACES the composer and its footer
// with a numbered option list, the selected one marked `›`, over a key-hint row:
// command approval (a network `› 1. Yes, proceed (y)` ...
// escalation too), edit approval
// ` Press enter to confirm or esc to cancel`
// question (request_user_input) ` › 1. Blue Write the color blue.` ...
// ` tab to add notes | enter to submit answer | esc to interrupt`
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
// workspace trust, at startup `› 1. Trust and continue`
// ` enter continue · esc quit`
// (the same prompt reached after the ` 2. Back to Agent Command Center`
// update prompt, 0.162.0, 2026-10-16) ` enter continue · esc back`
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
// the window. Below about 60 columns that hint wraps onto rows of its own
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
// `enter to submit answer` / `esc to interrupt` one per row), so the hint may run on
// over rows that start with two spaces and a lowercase word or arrow, and must end on
// one of its own closing words. The transcript can quote every one of those strings
// (measured: an answer that printed them verbatim) and still not match, because while
// no dialog is up codex keeps its composer row (`› ...`, column 0, which ends the
// run) and its footer below anything the agent prints. The longest option list
// measured, a question at 36 columns, put the `›` row 16 non-blank rows up; 20 leaves
// room for longer option text, and a dialog whose marker scrolls out of the window is
// simply not seen (the behaviour before this field). Not matched on purpose:
// `Implement this plan?` after a plan turn, whose `enter select · esc back` footer is
// the same as `/model`, `/permissions` and `/review`, menus the user opened at the
// keyboard; the plan turn has ended there, and the session reads idle as before.
// ⚠️ The question dialog's hint contains `esc to interrupt`, the working line, so
// such a pane read as WORKING before this field: a blocked screen now wins over it.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
@@ -1300,8 +1335,14 @@ const DEEPSEEK: CliEntry = {
launcherTargetParam: 'profile',
install: {
command: {
linux: 'npm install -g @deepseek-ai/dsh',
darwin: 'npm install -g @deepseek-ai/dsh',
// pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the
// terminal profile this CLI needs before it can run cannot be installed without it (#352).
// ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin
// directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm`
// refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure
// took dsh down with it, on exactly the machines that already had a working pnpm.
linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
},
npmPackage: '@deepseek-ai/dsh',
docsUrl: 'https://github.com/deepseek-ai/deepseek-harness',
+26
View File
@@ -406,6 +406,32 @@ export interface CliCapabilities {
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
/**
* Source of a regex matching the dialog this CLI draws when it stops and waits for the
* user to answer something (an approval, a question, a trust prompt): Codeman reads
* such a pane as BLOCKED, with the same red "needs you" alert, push, webhook and
* `blocked` wait signal a Claude `permission_prompt` hook raises. It is the source of
* that state for a CLI with no hook of its own (`hooks: 'none'`); a CLI whose hooks
* report it never consults the screen for it.
*
* Tested against the last `blockedLines` non-blank rows of a pane capture, joined
* with `\n`, so the pattern can require several markers at once and anchor on the
* end of the window (no `m` flag: `$` is the last row). It must anchor on chrome
* only that CLI draws, at the FOOT of the screen, where a dialog replaces the
* composer: the text above is the agent's own, and it can print anything. A forged
* match may cost a false alert and nothing else, because Codeman never answers such a
* dialog. See `isBlockedScreen()` in `session-activity.ts`.
*
* A CLI that omits it is never blocked from the screen, which is what every CLI was
* before the field existed.
*/
blockedLine?: string;
/**
* How many non-blank rows at the foot of the screen `blockedLine` sees. A dialog's
* options wrap at narrow widths, which is what pushes its markers up the screen, so
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
+953
View File
@@ -0,0 +1,953 @@
/**
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
* on one repository each get their own working tree instead of sharing the case folder (sessions
* in one case share its folder, and two agents editing, building and committing in one checkout
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
* `POST /api/sessions` with the worktree as `workingDir`).
*
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
* hooks there for every claude session, and an untracked copy in a worktree makes
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
*
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
* spawns git through an injectable runner.
*
* Rules the code keeps and the tests pin:
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
* process group, and capped output. Read-only calls add `--no-optional-locks`.
* - Names and branches are validated BEFORE they reach git (charset, length, then
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
* are refused up front, since `--branch` would expand them).
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
* worktree itself, is refused) and its real path must be exactly the expected one, so a
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
* race (an agent swapping a directory between the check and git), which is accepted: that
* agent already runs code as this user.
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
* untracked files, and that refusal is the answer), refused while a live session runs inside
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
* branch survives).
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
* since the user's agent runs code there anyway. A managed worktree that is, sits inside, or
* holds a Docker case workspace is never removed either: `git worktree remove` runs a status
* inside the tree, then deletes it under the container.
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
* is refused: the worktrees would not be where the case is.
*
* @module git-worktrees
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
// ─── Limits ──────────────────────────────────────────────────────────────────
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
export const MAX_WORKTREE_NAME_LENGTH = 64;
/** Longest accepted branch name. git allows more; 200 covers every real one. */
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
/**
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
*/
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
/** `git worktree remove` runs a status of the tree first, then deletes it. */
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
/** Captured stdout. `worktree list` is one short block per worktree. */
const MAX_STDOUT_BYTES = 1_000_000;
/** Captured stderr tail. */
const MAX_STDERR_BYTES = 8_192;
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
export const MAX_WORKTREES_LISTED = 200;
/** Path segments of the managed worktrees folder, relative to the repository root. */
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const;
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
/**
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
* is on), or null for a folder that is not a plain relative path.
*/
export function agentSkillExcludeLine(skillsDir: string): string | null {
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null;
return `/${skillsDir}/codeman/`;
}
// ─── Pure: names ─────────────────────────────────────────────────────────────
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
/**
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
* own ref rules, since the default branch is derived from the name).
*/
export function worktreeNameError(name: unknown): string | null {
if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.';
if (name.length > MAX_WORKTREE_NAME_LENGTH)
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
if (!WORKTREE_NAME_RE.test(name))
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
if (name.includes('..')) return 'A worktree name may not contain "..".';
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
return 'A worktree name may not end with "." or ".lock".';
return null;
}
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
export function defaultWorktreeBranch(name: string): string {
return `worktree-${name}`;
}
/**
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
*/
export function worktreeBranchError(branch: unknown): string | null {
if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.';
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
if (!BRANCH_NAME_RE.test(branch))
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".';
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
return 'A branch name may not end with "/", "." or ".lock".';
if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".';
return null;
}
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
export function worktreeBaseError(base: unknown): string | null {
if (typeof base !== 'string' || !isSafeGitRef(base))
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
return null;
}
// ─── Pure: paths ─────────────────────────────────────────────────────────────
/** `<repo>/.claude/worktrees`. */
export function managedWorktreesDir(repoRoot: string): string {
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
}
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
export function managedWorktreePath(repoRoot: string, name: string): string {
return join(managedWorktreesDir(repoRoot), name);
}
/**
* The managed worktree name of `path`, or null when it is not exactly
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
*/
export function managedWorktreeName(repoRoot: string, path: string): string | null {
const dir = managedWorktreesDir(repoRoot) + sep;
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
if (!clean.startsWith(dir)) return null;
const name = clean.slice(dir.length);
if (!name || name.includes(sep)) return null;
return worktreeNameError(name) === null ? name : null;
}
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
export function isPathWithin(child: string, root: string): boolean {
const c = resolve(child);
const r = resolve(root);
return c === r || c.startsWith(r === sep ? r : r + sep);
}
/**
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
* `workingDir` they were created with, while the worktree paths git lists are real, so without
* this a session spelled through the case's link counted for no worktree and did not block its
* removal.
*/
export function respellUnder(path: string, alias: string, root: string): string {
const p = resolve(path);
const a = resolve(alias);
if (a === resolve(root) || !isPathWithin(p, a)) return p;
return join(root, p.slice(a.length));
}
// ─── Pure: `git worktree list --porcelain` ───────────────────────────────────
export interface ParsedWorktree {
path: string;
/** Commit checked out (absent for a bare entry). */
head: string | null;
/** Short branch name (`refs/heads/` stripped), null when detached or bare. */
branch: string | null;
detached: boolean;
bare: boolean;
locked: boolean;
lockReason?: string;
prunable: boolean;
prunableReason?: string;
}
/**
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
*/
export function unquoteGitPath(text: string): string {
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text;
const body = text.slice(1, -1);
const bytes: number[] = [];
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\') {
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
continue;
}
const next = body[i + 1];
if (next !== undefined && /[0-7]/.test(next)) {
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0];
bytes.push(parseInt(oct, 8) & 0xff);
i += oct.length;
} else if (next !== undefined && next in simple) {
bytes.push(simple[next]);
i += 1;
} else {
bytes.push(92);
}
}
return Buffer.from(bytes).toString('utf-8');
}
/**
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
* repository paths readable.
*/
export function parseWorktreeList(text: string): ParsedWorktree[] {
const out: ParsedWorktree[] = [];
let cur: ParsedWorktree | null = null;
const flush = () => {
if (cur) out.push(cur);
cur = null;
};
for (const raw of text.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) {
flush();
continue;
}
const space = line.indexOf(' ');
const key = space === -1 ? line : line.slice(0, space);
const value = space === -1 ? '' : line.slice(space + 1);
if (key === 'worktree') {
flush();
cur = {
path: unquoteGitPath(value),
head: null,
branch: null,
detached: false,
bare: false,
locked: false,
prunable: false,
};
continue;
}
if (!cur) continue;
const entry: ParsedWorktree = cur;
if (key === 'HEAD') entry.head = value || null;
else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
else if (key === 'detached') entry.detached = true;
else if (key === 'bare') entry.bare = true;
else if (key === 'locked') {
entry.locked = true;
if (value) entry.lockReason = value;
} else if (key === 'prunable') {
entry.prunable = true;
if (value) entry.prunableReason = value;
}
}
flush();
return out;
}
// ─── Pure: exclude file ──────────────────────────────────────────────────────
/**
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
* newline gets one first, so an appended pattern never fuses with the user's last line.
*/
export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } {
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
const added = lines.filter((l) => !present.has(l));
if (added.length === 0) return { text: '', added };
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
return {
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
added,
};
}
// ─── Pure: git's answers ─────────────────────────────────────────────────────
/**
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
*/
export function gitRefusalText(stderr: string): string {
const clean = sanitizeGitOutput(stderr, 2000);
const lines = clean
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^hint:/i.test(l));
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
const text = (diagnostic.length ? diagnostic : lines).join('\n');
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
}
// ─── IO: git runner ──────────────────────────────────────────────────────────
export interface WorktreeGitResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
spawnError?: string;
}
/** Runs `git <args>` in `cwd`; never throws. Injected so tests can refuse to run git at all. */
export type WorktreeGitRunner = (
cwd: string,
args: string[],
opts?: { timeoutMs?: number }
) => Promise<WorktreeGitResult>;
/**
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
* no path quoting of non-ASCII names. Must precede the subcommand.
*/
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
/**
* The production runner: argv only (no shell), the non-interactive environment, captured output
* caps, and a timeout that signals the whole process group (`detached: true`), because
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
* parent alone would leave running.
*/
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
new Promise<WorktreeGitResult>((resolveRun) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
cwd,
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
return;
}
let stdout = '';
let stdoutBytes = 0;
let stderr = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
});
const finish = (result: WorktreeGitResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolveRun(result);
};
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
});
// ─── IO: results ─────────────────────────────────────────────────────────────
/** Failure codes, named after the API error codes the routes map them to. */
export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED';
export interface WorktreeFailure {
ok: false;
code: WorktreeFailureCode;
message: string;
/** git's own words, when git refused (sanitized; render as text). */
gitMessage?: string;
}
const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({
ok: false,
code,
message,
...(gitMessage ? { gitMessage } : {}),
});
function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure {
if (run.spawnError && /enoent/i.test(run.spawnError))
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`);
const text = gitRefusalText(run.stderr);
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
}
// ─── IO: the case's repository ───────────────────────────────────────────────
export interface CaseRepository {
/** The repository's top level, which is the case folder (real path). */
repoRoot: string;
/** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */
commonDir: string;
}
export interface WorktreeOptions {
git?: WorktreeGitRunner;
/** Host paths of every Docker case workspace: git never runs at or inside one. */
dockerWorkspaces?: string[];
/** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */
home?: string;
/**
* Every workspace skills folder a session can get Codeman's agent skill in (the registry's
* `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes
* `<dir>/codeman/` for each, like the hooks file: a session in a worktree gets an untracked
* copy there, and `git worktree remove` would refuse that worktree forever.
*/
agentSkillDirs?: readonly string[];
}
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return resolve(p);
}
};
const DOCKER_REFUSAL =
'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
const DOCKER_WORKTREE_REFUSAL =
'This worktree is, or holds, a Docker case workspace: git would run inside a folder a container can write, and removing it would delete that workspace. Remove the Docker case first.';
/**
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
*/
export async function resolveCaseRepository(
casePath: string,
opts: WorktreeOptions = {}
): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) {
if (/must be run in a work tree/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
}
if (/not a git repository/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return gitFailure(run, 'reading the repository');
}
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.');
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
const commonDir = await realOr(resolve(casePath, common));
if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
if (repoRoot !== caseReal) {
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return fail(
'INVALID_INPUT',
`Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`
);
}
return { ok: true, repo: { repoRoot, commonDir } };
}
// ─── IO: listing ─────────────────────────────────────────────────────────────
export interface CaseWorktree extends ParsedWorktree {
/** The repository's main checkout (git lists it first). */
main: boolean;
/** The case folder itself (the main checkout, or a linked worktree linked as the case). */
isCase: boolean;
/** Under `<repo>/.claude/worktrees/<valid-name>`: Codeman may start sessions in it and remove it. */
managed: boolean;
/** The managed name, when `managed`. */
name?: string;
}
export async function listCaseWorktrees(
repo: CaseRepository,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees');
const parsed = parseWorktreeList(run.stdout);
const truncated = parsed.length > MAX_WORKTREES_LISTED;
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => {
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
return {
...wt,
main: index === 0,
isCase: wt.path === repo.repoRoot,
managed: name !== null,
...(name ? { name } : {}),
};
});
return { ok: true, worktrees, truncated };
}
// ─── IO: path guards ─────────────────────────────────────────────────────────
type DirState = 'dir' | 'absent' | 'symlink' | 'other';
async function lstatState(path: string): Promise<DirState> {
try {
const st = await fs.lstat(path);
if (st.isSymbolicLink()) return 'symlink';
return st.isDirectory() ? 'dir' : 'other';
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return 'absent';
throw err;
}
}
/**
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
*/
async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> {
let cur = repoRoot;
for (const segment of WORKTREES_DIR_SEGMENTS) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'symlink') {
return fail(
'INVALID_INPUT',
`${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`
);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
if (state === 'absent') {
try {
await fs.mkdir(cur);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`);
}
if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
}
}
}
if ((await realOr(cur)) !== cur) {
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
}
return { ok: true, dir: cur };
}
/**
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
* repository. Used by removal and by starting a session in it.
*
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
* used again (the branch exists, then git refuses "a missing but already registered worktree").
*/
export async function resolveManagedWorktree(
repo: CaseRepository,
name: string,
opts: WorktreeOptions & { allowMissing?: boolean } = {}
): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> {
const nameError = worktreeNameError(name);
if (nameError) return fail('INVALID_INPUT', nameError);
const target = managedWorktreePath(repo.repoRoot, name);
let cur = repo.repoRoot;
let missing = false;
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'absent') {
if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
// Nothing below an absent folder exists, so nothing below it can redirect anything.
missing = true;
break;
}
if (state === 'symlink') {
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`);
}
if (!missing && (await realOr(target)) !== target) {
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
}
const listed = await listCaseWorktrees(repo, opts);
if (!listed.ok) return listed;
for (const wt of listed.worktrees) {
if (wt.main) continue;
if (missing) {
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
// lock check then refuses it with git's lock reason.
if (wt.path === target && (wt.prunable || wt.locked)) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
}
continue;
}
if (wt.path === target || (await realOr(wt.path)) === target) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
}
}
if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
return fail(
'NOT_FOUND',
`${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`
);
}
// ─── IO: excludes ────────────────────────────────────────────────────────────
/**
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
* written through).
*/
async function ensureExcluded(
repo: CaseRepository,
probes: Array<{ line: string; probe: string }>,
git: WorktreeGitRunner
): Promise<{ added: string[]; warning?: string }> {
const needed: string[] = [];
for (const { line, probe } of probes) {
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
if (run.code !== 0) needed.push(line);
}
if (needed.length === 0) return { added: [] };
const infoDir = join(repo.commonDir, 'info');
const excludePath = join(infoDir, 'exclude');
try {
const infoState = await lstatState(infoDir);
if (infoState === 'absent') await fs.mkdir(infoDir);
else if (infoState !== 'dir')
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
const fileState = await lstatState(excludePath);
if (fileState === 'symlink' || fileState === 'dir') {
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
}
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
const { text, added } = excludeAppendText(existing, needed);
if (text) await fs.appendFile(excludePath, text, { mode: 0o644 });
return { added };
} catch (err) {
return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` };
}
}
// ─── IO: one operation per repository at a time ──────────────────────────────
const repoLocks = new Map<string, Promise<unknown>>();
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
const next = prev.then(fn, fn);
const tail = next.catch(() => undefined);
repoLocks.set(repoRoot, tail);
try {
return await next;
} finally {
if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot);
}
}
// ─── IO: create ──────────────────────────────────────────────────────────────
export interface CreateWorktreeRequest {
name: string;
/** New branch; default `worktree-<name>`. */
branch?: string;
/** Start point; default `HEAD` (the case's current commit). */
base?: string;
}
export interface CreatedWorktree {
name: string;
path: string;
branch: string;
base: string;
/** The commit the new branch starts at. */
head: string;
/** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */
excludeAdded: string[];
warnings: string[];
}
export async function createCaseWorktree(
repo: CaseRepository,
req: CreateWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const nameError = worktreeNameError(req.name);
if (nameError) return fail('INVALID_INPUT', nameError);
const branch = req.branch || defaultWorktreeBranch(req.name);
const branchError = worktreeBranchError(branch);
if (branchError) return fail('INVALID_INPUT', branchError);
const base = req.base || 'HEAD';
const baseError = worktreeBaseError(base);
if (baseError) return fail('INVALID_INPUT', baseError);
return withRepoLock(repo.repoRoot, async () => {
// A name already taken says so in those words, before the branch check would report the
// derived branch instead. Rechecked after the folders are prepared, below.
try {
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
}
} catch {
/* unreadable: the check after the folders are prepared reports it */
}
// git's own word on the branch name. The output must be the input: `--branch` expands
// shorthands, and anything it rewrote is not the name the user typed.
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
}
const existingBranch = await git(
repo.repoRoot,
[...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
if (existingBranch.code === 0) {
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
}
const resolved = await git(
repo.repoRoot,
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
// leading `-`, so it cannot be read as an option.
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
const head = resolved.stdout.trim();
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
}
const prepared = await prepareWorktreesDir(repo.repoRoot);
if (!prepared.ok) return prepared;
const target = join(prepared.dir, req.name);
try {
if ((await lstatState(target)) !== 'absent') {
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
}
} catch (err) {
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`);
}
const skillProbes: Array<{ line: string; probe: string }> = [];
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
const line = agentSkillExcludeLine(dir);
if (line) skillProbes.push({ line, probe: `${dir}/codeman` });
}
const excluded = await ensureExcluded(
repo,
[
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
...skillProbes,
],
git
);
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
});
if (add.code !== 0 || add.spawnError || add.timedOut) {
const text = gitRefusalText(add.stderr);
if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
return gitFailure(add, 'creating the worktree');
}
return {
ok: true,
worktree: {
name: req.name,
path: target,
branch,
base,
head,
excludeAdded: excluded.added,
warnings: excluded.warning ? [excluded.warning] : [],
},
};
});
}
// ─── IO: remove ──────────────────────────────────────────────────────────────
export interface RemoveWorktreeRequest {
name: string;
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch?: boolean;
/** True when a live session runs at or inside `path`: the removal is refused. */
isInUse: (path: string) => boolean | Promise<boolean>;
}
export interface RemovedWorktree {
name: string;
path: string;
branch: string | null;
branchDeleted: boolean;
/** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */
branchError?: string;
}
export async function removeCaseWorktree(
repo: CaseRepository,
req: RemoveWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
return withRepoLock(repo.repoRoot, async () => {
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
if (!found.ok) return found;
// The case passed the Docker rule, but the worktree folder itself can be a Docker case's
// workspace (a Docker case linked to it), or hold one. `git worktree remove` runs a status
// INSIDE the tree first (container-written files, checkout filters on this machine), then
// deletes the whole tree, ignored files included, under a container that still mounts it.
// A folder that is gone has neither problem: git only drops its record.
if (!found.missing) {
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (dockerRoots.some((root) => isPathWithin(found.path, root) || isPathWithin(root, found.path))) {
return fail('INVALID_INPUT', DOCKER_WORKTREE_REFUSAL);
}
}
if (await req.isInUse(found.path)) {
return fail(
'CONFLICT',
`A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`
);
}
if (found.worktree.locked) {
return fail(
'CONFLICT',
`Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`
);
}
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
});
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree');
const text = gitRefusalText(removed.stderr);
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
// the answer, in git's words.
return fail(
'CONFLICT',
text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.',
text
);
}
const branch = found.worktree.branch;
let branchDeleted = false;
let branchError: string | undefined;
if (req.deleteBranch && branch) {
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
if (del.code === 0) branchDeleted = true;
else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
}
return {
ok: true,
removed: {
name: req.name,
path: found.path,
branch,
branchDeleted,
...(branchError ? { branchError } : {}),
},
};
});
}
+2 -1
View File
@@ -270,7 +270,8 @@ export function buildToolDefinitions(modes: readonly string[]): ToolDefinition[]
'prompt, idle, working, exit) or prints a literal marker (match). Returns timed_out when timeout_ms passes: ' +
'call again to keep waiting. Your own prompt is echoed into the output, so a marker written whole in the ' +
'prompt matches at once: ask for it in two halves ("print DONE followed by _7f3a") and match the joined ' +
'form (DONE_7f3a), one word without spaces. stop and blocked exist only for claude and deepseek sessions.',
'form (DONE_7f3a), one word without spaces. stop exists only for claude and deepseek sessions; blocked ' +
'also for codex, whose dialogs Codeman reads off its screen.',
inputSchema: {
type: 'object',
properties: {
+23
View File
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
if (value !== undefined) item[key] = value;
}
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
*/
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
const m = WORKTREE_PATH.exec(workingDir);
return m ? { repo: m[1], name: m[2] } : null;
}
/**
* Merge all source views into one list, applying precedence
* (history → lifecycle → persisted → live) and the meaningfulness floor.
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (byDir) item.lastPrompt = byDir.prompt;
}
}
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
// shell session never gets one, so its row read like any other session of the repo. The
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
if (!item.worktreeName && item.workingDir) {
const wt = worktreeFromPath(item.workingDir);
if (wt) {
item.worktreeName = wt.name;
item.worktreeRepo ??= wt.repo;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
+71
View File
@@ -159,6 +159,77 @@ export function watchingLabel(
return null;
}
/**
* How many non-blank rows at the foot of a pane capture `isBlockedScreen()` reads for a
* CLI that declares no number of its own (`capabilities.workDetect.blockedLines`).
*/
export const BLOCKED_TAIL_LINES = 1;
/** Hard cap on that window, whatever a registry entry asks for (the schema's own max). */
export const BLOCKED_MAX_TAIL_LINES = 24;
/**
* How often a session that is showing a blocking dialog re-reads its screen to learn the
* dialog went away. The raise needs no timer, because a dialog is drawn at the END of a
* burst of output and the idle confirmation reads the screen once the pane goes quiet. The
* clear does: answering resumes the turn, and a running turn keeps the pane busy, so the
* settle path would not look again until the whole turn ended, and the red alert would
* outlive the dialog by minutes. One `capture-pane` per interval, and only while a dialog
* is up.
*/
export const BLOCKED_RECHECK_MS = 2000;
/**
* The longest a session showing a blocking dialog goes without re-reading a QUIET pane.
* A dialog leaves the screen only through a repaint, and every repaint reaches the
* session as PTY output, so while nothing has been drawn since the last reading that saw
* the dialog it is still up, and the BLOCKED_RECHECK_MS read is skipped. That read is a
* synchronous `tmux capture-pane`, about 8 ms of blocked event loop (measured 2026-10-14),
* and a dialog can sit unanswered overnight: one per 2 s per waiting session for hours.
* This bound is only the safety net under that reasoning, never the clear's latency.
*/
export const BLOCKED_QUIET_RECHECK_MS = 30_000;
/**
* Consecutive readable captures without the dialog before the blocked state clears. One
* read can land mid-repaint (a resize redraws the whole frame), and a clear followed by a
* re-raise would send the alert, the push and the webhook a second time for the same
* dialog. Two reads at BLOCKED_RECHECK_MS clear 2 to 4 s after the answer.
*/
export const BLOCKED_CLEAR_READS = 2;
/**
* Whether the foot of a pane shows a dialog this CLI blocks on (an approval, a question,
* a trust prompt), read with the CLI's `capabilities.workDetect.blockedLine`.
*
* The last `tailLines` non-blank rows are joined with `\n` and tested once, so the pattern
* can demand several markers and anchor its footer on the end of the window. Blank rows
* are dropped first, like `watchingLabel()`, because a TUI pads its dialog with them.
*
* ⚠️ The text is pane-derived and the agent writes most of the pane, so a pattern must
* end on chrome only the CLI draws (a dialog's key-hint footer replaces the composer and
* its footer, which nothing the agent prints can sit below). Even so, a match is only
* ever an ALERT: nothing keys a keystroke off it.
*
* @param tailLines how many non-blank rows from the bottom the pattern sees, defaulting to
* `BLOCKED_TAIL_LINES` and capped at `BLOCKED_MAX_TAIL_LINES`
*/
export function isBlockedScreen(
paneText: string | null | undefined,
pattern: RegExp,
tailLines: number = BLOCKED_TAIL_LINES
): boolean {
if (!paneText) return false;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd())
.filter((row) => row !== '');
const window = rows.slice(-Math.max(1, Math.min(tailLines, BLOCKED_MAX_TAIL_LINES))).join('\n');
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
pattern.lastIndex = 0;
return pattern.test(window);
}
/**
* How many rows above the composer the turn's closing row may sit. Between the two Claude
* draws only its composer border and, sometimes, a right-aligned hint
+1 -1
View File
@@ -4,7 +4,7 @@
*
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
+204 -8
View File
@@ -89,7 +89,12 @@ import {
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
isBlockedScreen,
WATCHING_TAIL_LINES,
BLOCKED_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_QUIET_RECHECK_MS,
BLOCKED_CLEAR_READS,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -563,6 +568,7 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _lastPaneProbeTrustDialog = false; // Same capture showed the workspace-trust dialog (see _confirmIdle)
/**
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
*
@@ -596,6 +602,23 @@ export class Session extends EventEmitter {
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.blockedLine`. See _blockedLinePattern(). */
private _blockedLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
private _blockedWindow = BLOCKED_TAIL_LINES;
/**
* The pane is showing a dialog the CLI blocks on, read off the screen with
* `capabilities.workDetect.blockedLine` (codex: an approval, a question, the trust
* prompt). Live state, not persisted: a restarted server re-reads it from the attach
* repaint the same way the watching label comes back. See `_noteBlockedReading()`.
*/
private _screenBlocked = false;
/** Consecutive readable captures without the dialog (cleared at BLOCKED_CLEAR_READS). */
private _blockedGoneReads = 0;
/** When the last reading that SAW the dialog was taken. See `_armBlockedRecheck()`. */
private _blockedSeenAt = 0;
/** Re-reads the screen while a dialog is up, so the alert clears with it. */
private _blockedRecheckTimer: NodeJS.Timeout | null = null;
/**
* The newest model the running CLI reported for itself (its statusline, or its own
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
@@ -1429,6 +1452,16 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* True while the pane shows a dialog its CLI is blocked on, as read off the screen
* (`capabilities.workDetect.blockedLine`). Always false for a CLI that declares none,
* Claude included: its dialogs arrive as hook events instead. The `blocked` wait reads
* it so a caller that asks after the dialog appeared is answered at once.
*/
get screenBlocked(): boolean {
return this._screenBlocked;
}
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
@@ -2711,6 +2744,7 @@ export class Session extends EventEmitter {
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
this._lastPaneProbeTrustDialog = false; // the last pane's verdict, not this one's
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
@@ -3081,6 +3115,10 @@ export class Session extends EventEmitter {
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
// Nothing reads this pane any more, so a dialog on it can no longer be seen to
// close: drop the state (and its timer) while the listeners are still attached.
// A re-attach re-reads the screen and raises it again if the dialog is still up.
this._clearScreenBlocked();
// If using mux, mark the session as detached but don't kill it
if (this._muxSession && this._mux) {
this._mux.setAttached(this.id, false);
@@ -3143,13 +3181,24 @@ export class Session extends EventEmitter {
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
private _maybeAcceptTrustDialog(): void {
if (this._trustDialogAccepted) return;
// Stopped: the kill itself produces PTY output, and the buffer tail still holds the
// dialog, so without this a deleted session kept "answering" it for several seconds.
if (this._trustDialogAccepted || this._isStopped) return;
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
return;
}
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
const sinceScan = now - this._lastTrustDialogScanAt;
if (sinceScan < TRUST_DIALOG_RETRY_MS) {
// ⚠️ Defer a throttled read, never drop it. The dialog's own paint is usually the
// last output a launching pane produces, and it tends to land inside the throttle
// of the read the blank screen before it triggered: dropped, nothing re-read the
// screen and the session sat on the dialog for good (2 of 12 fresh launches on
// Claude Code 2.1.294). A read already scheduled covers this one.
if (!this._trustDialogTimer) this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS - sinceScan + 100);
return;
}
this._lastTrustDialogScanAt = now;
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
@@ -3184,14 +3233,17 @@ export class Session extends EventEmitter {
// on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is
// one-shot and self-rearming through this same path, and every exit route
// goes through _clearAllTimers().
// The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at
// exactly the throttle boundary would let the scan return early and break
// the chain with the dialog still on screen.
// The +100ms puts the re-entry OUTSIDE the scan throttle above.
this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS + 100);
}
/** Re-run the trust-dialog scan after `delayMs`, replacing any read already scheduled. */
private _rescanTrustDialogIn(delayMs: number): void {
if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = setTimeout(() => {
this._trustDialogTimer = null;
this._maybeAcceptTrustDialog();
}, TRUST_DIALOG_RETRY_MS + 100);
}, delayMs);
}
/**
@@ -3265,10 +3317,31 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
// A dialog the CLI is blocked on is not work, whatever else is on screen: codex's
// question dialog carries `esc to interrupt` in its own key hints, which is codex's
// working line, so without this a session waiting on the user's answer read as busy.
// Decided from THIS capture, never from `_screenBlocked`, which lags a vanished dialog
// by design (see `_noteBlockedReading`) and would otherwise call a resumed turn idle.
const blockedOnScreen = text === null ? null : this._paneShowsBlocked(text);
this._noteBlockedReading(blockedOnScreen, text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
text === null
? null
: !blockedOnScreen && (this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text));
// A capture that failed proves nothing either way, so it keeps the last answer.
if (text !== null) {
const trustDialog = isTrustDialogScreen(text);
if (
trustDialog &&
!this._lastPaneProbeTrustDialog &&
now - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS
) {
console.log(`[Session] Holding idle while the workspace trust dialog is on screen: ${this.id}`);
}
this._lastPaneProbeTrustDialog = trustDialog;
}
this._readWatching(text);
this._readScreenModel(text);
return this._lastPaneProbeWorking;
@@ -3462,6 +3535,111 @@ export class Session extends EventEmitter {
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching the dialog this CLI blocks on, or null for a CLI whose registry
* entry declares none (every CLI but codex today, Claude included: its dialogs arrive
* as hook events). Compiled once per session through `compileVersionRegex()`, the same
* guard the schema applies, so a pattern it refuses yields null rather than a throw.
*/
private _blockedLinePattern(): RegExp | null {
if (this._blockedLineRe === undefined) {
const detect = getCli(this.mode)?.capabilities.workDetect;
this._blockedLineRe = detect?.blockedLine ? compileVersionRegex(detect.blockedLine) : null;
this._blockedWindow = detect?.blockedLines ?? BLOCKED_TAIL_LINES;
}
return this._blockedLineRe;
}
/** Whether this capture shows a dialog the CLI is blocked on. See `isBlockedScreen()`. */
private _paneShowsBlocked(paneText: string): boolean {
const pattern = this._blockedLinePattern();
return pattern ? isBlockedScreen(paneText, pattern, this._blockedWindow) : false;
}
/**
* Fold one screen reading into the blocked state, announcing each transition with
* `blockedChanged` (the web server turns it into the alert and the `blocked` signal).
*
* The two directions are deliberately unequal:
* - RAISE on one reading. A match needs the dialog's own footer at the foot of the
* screen, which is only there when the dialog is fully drawn.
* - CLEAR on BLOCKED_CLEAR_READS readings in a row. A single capture can land inside a
* full-frame repaint, and clearing on it would re-raise a moment later and send the
* alert, push and webhook twice for one dialog.
* A capture that could not be read (`null`) is no evidence either way and changes
* nothing, so a tmux hiccup can neither raise nor drop an alert. That is the opposite
* of `_readWatching`, and for the same reason: each degrades toward the alert.
*
* While blocked, `_blockedRecheckTimer` keeps reading, since answering resumes the turn
* and a busy pane never reaches the settle path that raised it.
*
* @param frame the capture the reading came from; rides the raise as the alert's context
*/
private _noteBlockedReading(onScreen: boolean | null, frame: string | null): void {
if (onScreen === null || !this._blockedLinePattern()) return;
if (onScreen) {
this._blockedGoneReads = 0;
// Stamped after the capture returned: output processed later is newer than it.
this._blockedSeenAt = Date.now();
if (!this._screenBlocked) {
this._screenBlocked = true;
this.emit('blockedChanged', true, frame);
}
this._armBlockedRecheck();
return;
}
if (!this._screenBlocked) return;
if (++this._blockedGoneReads < BLOCKED_CLEAR_READS) {
this._armBlockedRecheck();
return;
}
this._clearScreenBlocked();
}
/** (Re)arm the while-blocked screen read. One-shot and self-rearming, like the trust scan. */
private _armBlockedRecheck(): void {
if (this._blockedRecheckTimer) clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = setTimeout(() => {
this._blockedRecheckTimer = null;
// No pane left to read (detached): nothing can change until a re-attach, whose
// `_resetBuffers()` clears the state and whose repaint reads the screen afresh.
if (this._isStopped || !this._screenBlocked || !this._mux?.capturePaneText || !this._muxSession) return;
// A dialog leaves the screen only through a repaint, and tmux sends every repaint
// to this client as PTY output (`_markActivity`). So while the last reading saw
// the dialog and nothing has been drawn since, it is still up: skip the capture
// (synchronous, see BLOCKED_QUIET_RECHECK_MS) and look again next interval. Never
// once a reading has missed it: the confirming read must happen on a quiet pane
// too, or the clear would wait for output that may never come.
if (
this._blockedGoneReads === 0 &&
this._lastActivityAt < this._blockedSeenAt &&
Date.now() - this._blockedSeenAt < BLOCKED_QUIET_RECHECK_MS
) {
this._armBlockedRecheck();
return;
}
const text = this._mux.capturePaneText(this._muxSession.muxName) ?? null;
// Unreadable this time: keep the state, keep looking (_noteBlockedReading ignores a null).
if (text === null) {
this._armBlockedRecheck();
return;
}
this._noteBlockedReading(this._paneShowsBlocked(text), text);
}, BLOCKED_RECHECK_MS);
}
/** Drop the blocked state (and its timer), announcing it when it was set. */
private _clearScreenBlocked(): void {
this._blockedGoneReads = 0;
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
if (!this._screenBlocked) return;
this._screenBlocked = false;
this.emit('blockedChanged', false, null);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -3523,6 +3701,16 @@ export class Session extends EventEmitter {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
// ⚠️ The workspace-trust dialog is not a composer, although its `❯` (on "No, exit")
// arms this check and the dialog sits quiet. Calling it idle let an agent that waited
// for idle send its prompt into the dialog, and the prompt's Enter answered *exit*.
// Not working either, so the status stays where it is until the dialog is gone.
// Bounded by the auto-accept's own startup window: later on, a screen that merely
// quotes the dialog (an agent reading session-trust-dialog.ts) must not hold idle off.
if (this._lastPaneProbeTrustDialog && Date.now() - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS) {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
this._awaitingIdleConfirmation = false;
this.activityTimeout = null;
// Emit idle if either:
@@ -4004,6 +4192,9 @@ export class Session extends EventEmitter {
// into a program that never enabled tracking.
this._cliMouseModes.clear();
this._syncCliMouseTracking();
// Nor does it start inside a dialog. Announced, so a pending alert for the old
// pane's dialog is resolved rather than left behind it.
this._clearScreenBlocked();
this._markActivity(true);
}
@@ -4016,6 +4207,11 @@ export class Session extends EventEmitter {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// Clear the while-blocked screen read (the state itself is left to its owner)
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
@@ -4527,7 +4723,7 @@ export class Session extends EventEmitter {
*
* More reliable than direct PTY write for programmatic input, especially
* with Claude CLI which uses Ink (React for terminals).
* Uses tmux `send-keys -l` to inject text + Enter.
* Uses tmux to inject the text (one bracketed paste) + Enter.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no mux session or PTY
+128 -23
View File
@@ -7,13 +7,13 @@
* - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session
* - **Reliable input**: `send-keys -l` sends literal text in a single command
* - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
*
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
*
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput)
* - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture
@@ -436,6 +436,84 @@ export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): str
return `${muxName}.${paneTarget}`;
}
let submitBufferSeq = 0;
/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */
export interface PasteTextCommands {
/** Fills a buffer of its own with the text. */
set: string;
/** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */
paste: string;
/** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */
discard: string;
}
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;`
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
* anywhere else in the argument is literal already. Exported for unit testing.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
}
/**
* Whether a text+Enter submit types `text` as keys instead of pasting it: true for
* a single character. Claude Code's and Codex's selection dialogs ignore a bracketed
* paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3;
* a typed `3` selects it. One character cannot form the burst Codex reads as a paste,
* so typing it is safe there. Exported for unit testing.
*/
export function submitsAsKeys(text: string): boolean {
return [...text].length === 1;
}
/**
* tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing.
*
* Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT
* bracketed-paste markers heuristically, from characters arriving in a fast burst,
* and folds an Enter that lands inside that burst window into the paste as a
* newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter
* pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as
* `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt,
* so nothing re-presses Enter), intermittently for short text at the 50 ms gap.
* `paste-buffer -p` wraps the text in the markers (only when the pane asked for
* them, so an app without bracketed paste sees the same bytes as before) and the
* Enter after it is unambiguous.
*
* Each call gets its own named buffer so concurrent sends cannot paste each other's
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails.
*
* A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* A final `;` is escaped (escapeTmuxTrailingSemicolon).
*/
export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands {
const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`;
return {
set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`,
paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`,
discard: `${tmux} delete-buffer -b ${buffer}`,
};
}
/**
* Run a bracketed paste. A failed paste (the pane or the server is gone) deletes
* the buffer before the paste's own error propagates; a failing delete is ignored.
*/
export async function pasteText(run: (cmd: string) => Promise<unknown>, cmds: PasteTextCommands): Promise<void> {
await run(cmds.set);
try {
await run(cmds.paste);
} catch (err) {
await run(cmds.discard).catch(() => undefined);
throw err;
}
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
@@ -3738,10 +3816,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Send input directly to a tmux session using `send-keys`.
* Send input directly to a tmux session.
*
* Uses tmux send-keys for reliable input delivery:
* - `-l` flag sends literal text (no key interpretation)
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
@@ -3776,19 +3856,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// character in the input buffer rather than as form submission. The text goes
// in as a bracketed paste so Codex cannot fold the Enter into it either
// (see buildPasteTextCommands). A single character is typed, because a
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else {
await pasteText(
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
@@ -3930,19 +4023,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
} else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
@@ -4076,7 +4175,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
if (!geometry) {
const trimmed = rawCapture.replace(/\n+$/g, '');
return trimmed ? `${normalizeScrollbackEol(trimmed)}\x1b[0m` : '';
}
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
@@ -4090,7 +4192,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if (!hasVisibleContent(trimmed)) return '';
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
// capture-pane -e can leave the last row's SGR active (e.g. a nested
// tmux status bar). Hand live output the same default style as a visible
// snapshot, resetting before the cursor move so its position is kept.
return `${normalizeScrollbackEol(trimmed)}\x1b[0m${formatCursorRestore(geometry)}`;
}
const buffer = rawCapture.replace(/\n+$/g, '');
@@ -4099,7 +4204,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
return normalizeScrollbackEol(buffer);
return buffer ? `${normalizeScrollbackEol(buffer)}\x1b[0m` : '';
} catch (err) {
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
+2 -1
View File
@@ -84,7 +84,8 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0
*/
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
const DEEPSEEK_NOT_FOUND =
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)';
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
export function resolveDshHome(): string {
+13 -2
View File
@@ -15,7 +15,9 @@
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
* - Items are in-memory only. A server restart drops them; the next prompt
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
@@ -24,7 +26,8 @@
*
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
* dialogs), web/server (emit callbacks + stop)
*
* @module web/approval-inbox
*/
@@ -122,6 +125,13 @@ interface NotePromptArgs {
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
/**
* A frame the caller already read, kept as the card's context when there is no
* `capture`. Never parsed for options and never re-read: an item raised from the
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
* the option parser and staleness sweep here are shaped for Claude's dialogs.
*/
context?: string;
}
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -279,6 +289,7 @@ export class ApprovalInbox {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+99 -33
View File
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
// readline backward-char). The Alt block below claims only Digit1-9; the
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [],
action: 'removeTile',
},
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
{
id: 'previous-next-session',
id: 'previous-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
label: 'Previous Session',
displayBindings: ['Alt/Option+['],
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
action: 'previousSession',
},
{
id: 'next-session-alt',
group: 'Session',
label: 'Next Session (alternate)',
displayBindings: ['Alt/Option+]'],
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
action: 'nextSession',
},
{
id: 'switch-tab-n',
@@ -1388,19 +1399,7 @@ class CodemanApp {
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
const SHORTCUT_ACTIONS = this._shortcutActions();
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -1447,6 +1446,7 @@ class CodemanApp {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeWorktreeModal?.();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
@@ -1463,7 +1463,8 @@ class CodemanApp {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
// shortcut registry, so they can be rebound.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
@@ -1489,16 +1490,6 @@ class CodemanApp {
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
@@ -4843,6 +4834,8 @@ class CodemanApp {
}
}
if (data.version) this.checkWhatsNew?.(data.version);
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
@@ -10336,9 +10329,10 @@ class CodemanApp {
delete this.respawnActionLogs[sessionId];
}
/** Resolves true once the session is gone from the server, false when it is still there. */
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
if (this._closingSessions.has(sessionId)) return false;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
@@ -10416,7 +10410,7 @@ class CodemanApp {
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
return true;
}
// Still on the server (or the server is unreachable, in which case the resync
@@ -10431,6 +10425,7 @@ class CodemanApp {
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
return false;
}
// Request confirmation before closing a session
@@ -10445,6 +10440,20 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
const worktree = this._managedWorktreeOfSession?.(session) || null;
this.pendingCloseWorktree = worktree;
const wtOption = document.getElementById('closeConfirmWorktree');
if (wtOption) {
wtOption.hidden = !worktree;
const wtName = document.getElementById('closeConfirmWorktreeName');
if (wtName) wtName.textContent = worktree ? worktree.name : '';
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
if (wtBranch) wtBranch.checked = false;
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -10472,15 +10481,24 @@ class CodemanApp {
cancelCloseSession() {
this.pendingCloseSessionId = null;
this.pendingCloseWorktree = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
/**
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
* after the server confirms the session is gone (a live session inside is refused anyway),
* and only ever through `git worktree remove` without --force.
*/
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
const sessionId = this.pendingCloseSessionId;
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
const gone = await this.closeSession(sessionId, killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
@@ -10698,6 +10716,54 @@ class CodemanApp {
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
/**
* Action name -> handler for the shortcut registry entries the document keydown
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
*/
_shortcutActions() {
return {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
}
/**
* True when `e` is a chord the USER rebound to a generically dispatched action.
* The document handler has already run the action, but its preventDefault() does not
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
* and neither is a chord that matches the entry's own default (a re-capture of it).
*/
isUserBoundShortcutEvent(e) {
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
const actions = this._shortcutActions();
return this.getShortcutRegistry().some((shortcut) => {
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
// lets the chord through when the sidebar layout is off; swallowing it here would make a
// rebound sidebar key dead in the header layout.
if (shortcut.action === 'toggleSessionSidebar') return false;
if (typeof actions[shortcut.action] !== 'function') return false;
if (!this.matchesShortcutEvent(e, shortcut)) return false;
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
// CLI: clearTerminal() relies on ^L for the redraw.
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
return !this.matchesShortcutEvent(e, fallback);
});
}
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
+2 -2
View File
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2100,7 +2100,7 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
+73 -3
View File
@@ -168,8 +168,8 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
// view's drop (image-input.js) uses 'Only image or video files are supported'.
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
// view (image-input.js).
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
@@ -265,6 +265,50 @@
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -691,7 +735,7 @@
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'📎 Attach': '📎 添加附件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -798,9 +842,16 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -1179,6 +1230,8 @@
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
@@ -1244,6 +1297,10 @@
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
@@ -1252,6 +1309,16 @@
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
@@ -1287,6 +1354,9 @@
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
+19 -10
View File
@@ -103,7 +103,7 @@ Object.assign(CodemanApp.prototype, {
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (mediaFiles.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
this._uploadAndInsertImages(mediaFiles);
@@ -124,7 +124,7 @@ Object.assign(CodemanApp.prototype, {
async _attachFromToolbar(files, sessionId) {
const media = files.filter((f) => this._promptAttachKind(f));
if (media.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
if (!sessionId) return; // no captured session: refuse rather than guess at one
@@ -230,7 +230,7 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images or videos in clipboard items
// Check for images, videos or documents in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
@@ -266,18 +266,27 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', or 'video' for the camera
* roll's clips (the server streams those to disk under their own cap), else
* null. The one filter behind Attach, drop, the Compose and paste-dialog
* pickers and the clipboard handlers. The MIME type decides; some Android
* file managers hand over a File with none, so the name's extension is the
* fallback, mirroring the server's own allowlists. */
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
* handlers. For media the MIME type decides, but a video MIME only for the
* containers the server admits; some Android file managers hand over a File
* with none, so the name's extension is the fallback. A document is known by
* its extension alone, as on the server (the MIME a file manager gives a .md
* or .csv is anyone's guess). Mirrors the server's allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
// A video MIME counts only for the five containers the server accepts on
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
// extension check below and is refused up front, not after a 1GB upload and a 400.
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
return 'document';
}
return null;
},
+64 -1
View File
@@ -790,7 +790,7 @@
<path d="m21 15-5-5L5 21"/>
</svg>
</button>
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
</span>
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false">
@@ -1693,6 +1693,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1712,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -3663,6 +3721,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+14 -14
View File
@@ -1282,11 +1282,11 @@ const KeyboardAccessoryBar = {
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-image">📎 Attach</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1354,7 +1354,7 @@ const KeyboardAccessoryBar = {
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
@@ -1469,13 +1469,13 @@ const KeyboardAccessoryBar = {
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/*,video/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* - File (picker): the "Attach" button opens a native file picker
* (accept=image/*,video/* plus the document extensions → camera / photo
* library / files), the most reliable way to attach anything on mobile.
* - File (paste): if the browser exposes file blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -1488,13 +1488,13 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-image">📎 Attach</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1519,8 +1519,8 @@ const KeyboardAccessoryBar = {
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images and videos, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
// Filter to what the server admits, close the dialog, and hand off to the
// shared upload+insert pipeline. Returns true if any file was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
if (images.length === 0) return false;
@@ -1530,7 +1530,7 @@ const KeyboardAccessoryBar = {
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
// File picker (camera / photo library / files) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
+12 -2
View File
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win over the server's on the
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
// role was known) marks the page read-only for the writes after it.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
},
renderPresetDropdown() {
+525 -56
View File
@@ -92,7 +92,7 @@ const RUN_MODE_LAUNCH = {
},
deepseek: {
label: 'DeepSeek',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
// The two-part availability check is deliberate. `dsh` being installed is
// not enough — DeepSeek ships no terminal front door, so a box can have a
// perfect binary and nothing a pane can run.
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
/**
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
* actions, one click away, without two extra controls in the toolbar. The arrow
* keys reach them after the last case; Enter or a click runs `run`.
* actions, one click away, without two extra controls in the toolbar, and the git
* worktrees of the selected case joined them. The arrow keys reach them after the
* last case; Enter or a click runs `run`.
*/
const CASE_PICKER_ACTIONS = [
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
// toolbar button, for the same reason the two above replaced theirs.
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
];
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/**
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
*/
function managedWorktreeOfDir(workingDir, cases) {
if (typeof workingDir !== 'string') return null;
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
const owner = (cases || []).find(
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
);
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
}
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -604,6 +627,9 @@ Object.assign(CodemanApp.prototype, {
},
async saveLastUsedCase(caseName) {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
await fetch('/api/settings', {
method: 'PUT',
@@ -692,8 +718,18 @@ Object.assign(CodemanApp.prototype, {
this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Sync to server for cross-device persistence. Not for a non-admin in multi-user mode,
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
// on this device and wins over the server's on the next load (_rememberOwnSetting,
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
// read-only for the writes after it (_noteSettingsWriteRefusal).
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
}
// Close menu
document.getElementById('runModeMenu')?.classList.remove('active');
},
@@ -1919,12 +1955,16 @@ Object.assign(CodemanApp.prototype, {
},
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen
@@ -1933,25 +1973,32 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// A worktree is local by construction (the server refuses one for a remote or Docker
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
caseData = { path: wt.path, location: 'local' };
} else {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const workingDir = caseData.path;
@@ -2024,8 +2071,8 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -2033,7 +2080,7 @@ Object.assign(CodemanApp.prototype, {
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
sessionNames.push(`w${startNumber + i}-${nameBase}`);
}
// Build env overrides from global + case settings (case overrides global)
@@ -2140,40 +2187,49 @@ Object.assign(CodemanApp.prototype, {
},
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
'1;33'
);
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// Local by construction, folder from the worktree list (see runClaude).
caseData = { path: wt.path, location: 'local' };
} else {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
@@ -2206,13 +2262,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
sessionNames.push(`s${startNumber + i}-${nameBase}`);
}
// Step 1: Create all sessions in parallel
@@ -2331,16 +2387,19 @@ Object.assign(CodemanApp.prototype, {
buildConfig: () => null,
});
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
@@ -2369,13 +2428,15 @@ Object.assign(CodemanApp.prototype, {
// into `claude --settings`); none of these CLIs has an /effort. Each of
// the eight bodies this launcher replaced carried that rule as a comment.
const firstSessionId = await this._launchQuickStartInstances(
caseName,
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
wt ? wt.name : caseName,
tabCount,
entry.label,
(sessionName) => ({
caseName,
mode,
sessionName,
...(wt ? { worktree: wt.name } : {}),
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
@@ -4833,6 +4894,16 @@ Object.assign(CodemanApp.prototype, {
},
async saveCaseOrder(order) {
// Multi-user non-admin: the case order is ONE server-wide list (settings.json caseOrder)
// and PUT /api/cases/order is admin-only (403), so the move is not sent: say why, and put
// the list back the way the server orders it.
if (this._settingsReadOnly?.()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
const select = document.getElementById('quickStartCase');
await this.loadQuickStartCases(select?.value);
this.renderCaseManageList();
return;
}
try {
await fetch('/api/cases/order', {
method: 'PUT',
@@ -5007,6 +5078,404 @@ Object.assign(CodemanApp.prototype, {
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
// ═══════════════════════════════════════════════════════════════
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
// ═══════════════════════════════════════════════════════════════
//
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
// session in it at once. Sessions start through run() with `options.worktree`, so every run
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
// git's own refusals) renders through textContent and carries data-i18n-skip.
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
openWorktreeModal(caseName) {
const modal = document.getElementById('worktreeModal');
const name = caseName || document.getElementById('quickStartCase')?.value || '';
if (!modal || !name) return;
this._worktreeCase = name;
this._worktreeData = null;
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
const caseLabel = document.getElementById('worktreeModalCase');
if (caseLabel) caseLabel.textContent = name;
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
const input = document.getElementById(id);
if (input) input.value = '';
}
this._setWorktreeFormError('');
this._fillWorktreeModeSelect();
this._renderWorktreeList();
modal.classList.add('active');
void this._loadWorktrees();
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
},
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
showWorktreesFromMobile() {
this.closeMobileCasePicker();
this.openWorktreeModal();
},
closeWorktreeModal() {
const modal = document.getElementById('worktreeModal');
// Called on every Escape (app.js): a no-op unless the dialog is open.
if (!modal || !modal.classList.contains('active')) return;
modal.classList.remove('active');
this._worktreeCase = null;
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
},
_worktreesUrl(caseName, suffix = '') {
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
},
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
async _worktreeRequest(path, opts = {}) {
const res = await this._api(path, opts);
if (!res) return { ok: false, error: 'Codeman did not answer.' };
let body = null;
try {
body = await res.json();
} catch {
/* an empty or non-JSON body: the status says enough */
}
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
},
async _loadWorktrees() {
const caseName = this._worktreeCase;
if (!caseName) return;
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
this._setWorktreeStatus('Loading worktrees…');
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
this._worktreeData = r.ok ? r.data : null;
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
const createBtn = document.getElementById('worktreeCreateBtn');
if (createBtn) createBtn.disabled = !r.ok;
this._renderWorktreeList();
},
_setWorktreeStatus(text, isError = false) {
const el = document.getElementById('worktreeStatus');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
el.classList.toggle('worktree-status-error', !!isError);
},
_setWorktreeFormError(text) {
const el = document.getElementById('worktreeFormError');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
},
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
_fillWorktreeModeSelect() {
const select = document.getElementById('worktreeMode');
if (!select) return;
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
const modes = [];
for (const cli of registryCliCatalog()) {
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
}
if (modes.length === 0) {
// A cached page from before the registry catalogue: the built-in modes.
modes.push({ id: 'claude', label: 'Claude Code' });
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
modes.push({ id: 'shell', label: 'Terminal / Shell' });
}
select.replaceChildren(
...modes.map((m) => {
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.label;
return option;
})
);
const current = this._runMode || 'claude';
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
},
_selectedWorktreeMode() {
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
},
_renderWorktreeList() {
const list = document.getElementById('worktreeList');
if (!list) return;
list.replaceChildren();
const data = this._worktreeData;
if (!data) return;
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
if (!data.worktrees.some((wt) => wt.managed)) {
const empty = document.createElement('div');
empty.className = 'worktree-empty';
empty.textContent = 'No worktrees yet. Create one below.';
list.appendChild(empty);
}
},
_buildWorktreeRow(wt) {
const row = document.createElement('div');
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
const head = document.createElement('div');
head.className = 'worktree-row-head';
const label = document.createElement('span');
label.className = 'worktree-row-name';
label.setAttribute('data-i18n-skip', '');
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
head.appendChild(label);
const badge = (text, extra = '', skip = false) => {
const b = document.createElement('span');
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
if (skip) b.setAttribute('data-i18n-skip', '');
b.textContent = text;
head.appendChild(b);
};
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
if (wt.main) badge('main checkout');
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
if (wt.locked) badge('locked', 'worktree-badge-warn');
if (wt.prunable) badge('missing', 'worktree-badge-warn');
if (wt.sessionCount > 0) {
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
}
if (wt.managed) {
const actions = document.createElement('span');
actions.className = 'worktree-row-actions';
// A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still
// clears git's record of it, which is what frees its name for a new worktree.
if (!wt.prunable) {
const open = document.createElement('button');
open.type = 'button';
open.className = 'wt-btn wt-btn-sm';
open.textContent = 'Open';
open.title = 'Start a session in this worktree';
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
actions.appendChild(open);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
remove.textContent = 'Remove…';
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
actions.appendChild(remove);
head.appendChild(actions);
}
row.appendChild(head);
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
// so the long repository prefix (already on the main row) is not repeated on every row.
const path = document.createElement('div');
path.className = 'worktree-row-path';
path.setAttribute('data-i18n-skip', '');
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
path.title = wt.path;
row.appendChild(path);
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
return row;
},
_buildWorktreeRemoveConfirm(wt) {
const box = document.createElement('div');
box.className = 'worktree-remove-confirm';
const note = document.createElement('p');
note.className = 'worktree-remove-note';
note.textContent = wt.prunable
? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.'
: 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
box.appendChild(note);
let checkbox = null;
if (wt.branch) {
const lbl = document.createElement('label');
lbl.className = 'worktree-remove-branch';
checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const text = document.createElement('span');
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
lbl.append(checkbox, text);
box.appendChild(lbl);
}
if (this._worktreeRemoveError?.name === wt.name) {
const err = document.createElement('pre');
err.className = 'worktree-remove-error';
err.setAttribute('data-i18n-skip', '');
err.textContent = this._worktreeRemoveError.text;
box.appendChild(err);
}
const buttons = document.createElement('div');
buttons.className = 'worktree-remove-buttons';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'wt-btn wt-btn-sm';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
const confirm = document.createElement('button');
confirm.type = 'button';
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
confirm.textContent = 'Remove worktree';
confirm.addEventListener('click', async () => {
confirm.disabled = true;
try {
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
} finally {
confirm.disabled = false;
}
});
buttons.append(cancel, confirm);
box.appendChild(buttons);
return box;
},
confirmWorktreeRemoval(name) {
this._worktreeRemoveConfirm = name;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
cancelWorktreeRemoval() {
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
async removeWorktreeFromModal(name, deleteBranch) {
const caseName = this._worktreeCase;
if (!caseName) return;
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._worktreeRemoveError = { name, text: r.error };
this._renderWorktreeList();
return;
}
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
await this._loadWorktrees();
},
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
async removeCaseWorktree(caseName, name, deleteBranch) {
const query = deleteBranch ? '?deleteBranch=true' : '';
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
method: 'DELETE',
});
if (r.ok) {
const removed = r.data?.removed || { name };
if (removed.branchError) {
this.showToast?.(
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
'warning'
);
} else if (removed.branchDeleted) {
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
} else {
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
}
}
return r;
},
async createWorktreeFromModal() {
const caseName = this._worktreeCase;
if (!caseName) return;
const name = (document.getElementById('worktreeName')?.value || '').trim();
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
const base = (document.getElementById('worktreeBase')?.value || '').trim();
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
this._setWorktreeFormError(
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
);
return;
}
this._setWorktreeFormError('');
const mode = this._selectedWorktreeMode();
const btn = document.getElementById('worktreeCreateBtn');
if (btn) btn.disabled = true;
try {
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
method: 'POST',
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
});
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._setWorktreeFormError(r.error);
return;
}
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
} finally {
if (btn) btn.disabled = false;
}
},
async openWorktreeSession(name) {
const caseName = this._worktreeCase;
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
*/
async _launchInWorktree(worktree, mode) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
_managedWorktreeOfSession(session) {
if (!session || session.remote || session.docker) return null;
return managedWorktreeOfDir(session.workingDir, this.cases);
},
/**
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
*/
async _removeWorktreeAfterClose(wt, deleteBranch) {
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
if (r.ok) {
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
return;
}
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
duration: 15000,
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
});
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
+402 -37
View File
@@ -35,7 +35,11 @@ Object.assign(CodemanApp.prototype, {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
// A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see
// src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the
// notification does not claim it is Claude asking.
const screenMessage = data.source === 'screen' && data.message ? data.message : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue');
},
_onHookElicitationDialog(data) {
@@ -369,6 +373,39 @@ Object.assign(CodemanApp.prototype, {
return select?.value.trim() || '';
},
/**
* Announce a new release once per browser: one toast naming its headline changes.
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from
* CHANGELOG.md for each release. A browser that has never seen any version just records the
* current one, so a fresh install is not greeted with "what's new".
*/
async checkWhatsNew(runningVersion) {
if (this._whatsNewChecked) return;
this._whatsNewChecked = true;
const KEY = 'codeman-whats-new-seen';
let seen = null;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; }
if (seen === runningVersion) return;
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
if (!seen) { remember(); return; }
try {
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
if (!res.ok) return;
const notes = await res.json();
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return;
const shown = notes.highlights.slice(0, 3).join(' · ');
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
remember();
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', {
duration: 20000,
action: {
label: "See what's new",
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'),
},
});
} catch { /* offline or malformed: try again next load */ }
},
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
@@ -674,6 +711,10 @@ Object.assign(CodemanApp.prototype, {
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
// Multi-user non-admin only: remember what the form shows now, so Save can tell a real
// change to a server-wide setting from an untouched one (see the method).
this._captureSettingsFormBaseline();
},
/**
@@ -1843,8 +1884,12 @@ Object.assign(CodemanApp.prototype, {
regenBtn.textContent = 'Regenerate QR';
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
regenBtn.onclick = () => {
// A refusal is not a success: in multi-user mode this is admin-only (403).
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
.then(() => this.showToast('QR code regenerated', 'success'))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.showToast('QR code regenerated', 'success');
})
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
};
const card = container.parentElement;
@@ -1939,6 +1984,11 @@ Object.assign(CodemanApp.prototype, {
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return;
}
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
@@ -2265,12 +2315,22 @@ Object.assign(CodemanApp.prototype, {
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
try {
const body = await res.json();
if (body && body.error) message = body.error;
if (body && typeof body.errorCode === 'string') errorCode = body.errorCode;
} catch {
/* non-JSON body — use the default message */
}
// A non-admin in multi-user mode: PUT /api/settings is admin-only there (403 FORBIDDEN).
// That is not the no-password refusal below, so no security prompt and no retry. The
// callers skip the PUT for a known non-admin; this covers a role that had not resolved yet.
if (res.status === 403 && errorCode === 'FORBIDDEN') {
this._dismissTunnelConnecting?.();
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return true;
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
@@ -2319,6 +2379,12 @@ Object.assign(CodemanApp.prototype, {
},
async _tunnelPanelToggle(enable) {
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
this.closeTunnelPanel();
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
@@ -2655,6 +2721,13 @@ Object.assign(CodemanApp.prototype, {
},
};
// One-shot form read for _captureSettingsFormBaseline(): hand back what the form
// shows, before anything below is checked, stored, applied or sent.
if (this._readSettingsFormOnce === true) {
this._readSettingsFormOnce = false;
return settings;
}
// SettingsUpdateSchema is .strict() and checks codexModel with this same
// pattern, so one bad character 400s the WHOLE settings PUT while the toast
// still says "Settings saved". Refuse it here, before anything is persisted.
@@ -2677,6 +2750,14 @@ Object.assign(CodemanApp.prototype, {
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Multi-user mode, signed in as a non-admin: settings.json is one file for the whole
// server and PUT /api/settings answers 403 (system-routes.ts), so nothing is sent
// below. The per-device keys still save here exactly as before; every SYNCED key
// keeps the server's value, which the next page load would put back anyway, so this
// device never shows a server-wide change that did not happen.
const settingsReadOnly = this._settingsReadOnly();
const syncedChangeRefused = settingsReadOnly && this._keepServerSyncedSettings(settings, _prev);
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
@@ -2897,18 +2978,31 @@ Object.assign(CodemanApp.prototype, {
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// Multi-user mode, a non-admin whose role was not known when this save started
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
// admin): the server refused the PUT as admin-only. End it the way a known non-admin's save
// ends: every synced key back to the server's value on this device, and the one admin
// toast below instead of "Settings saved". The page is read-only from here on.
const refusedAsNonAdmin = !settingsReadOnly && (await this._noteSettingsWriteRefusal(res));
if (refusedAsNonAdmin) {
this._keepServerSyncedSettings(settings, _prev);
this.saveAppSettingsToStorage(settings);
}
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
@@ -2921,8 +3015,9 @@ Object.assign(CodemanApp.prototype, {
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
const modelConfigRefused = (await this.saveModelConfigFromSettings()) === true;
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
@@ -2931,12 +3026,18 @@ Object.assign(CodemanApp.prototype, {
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
// ONE toast for the whole save, and only when the user really changed a server-wide
// setting: a non-admin saving a font size hears "Settings saved" as everyone does.
// A refused PUT always gets it: the server said this user is not an admin.
this.showToast('Saved on this device. Server-wide settings can only be changed by an admin.', 'warning');
} else {
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
// starts nothing: the box only shows what the server already has)
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -2962,7 +3063,9 @@ Object.assign(CodemanApp.prototype, {
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
// `?? false`: a non-admin's save puts a synced key the server never stored back to
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -2971,6 +3074,147 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* True only for a signed-in NON-ADMIN in multi-user mode, where every server-wide settings
* write is refused with 403 FORBIDDEN (PUT /api/settings, PUT /api/execution/model-config and
* PUT /api/cases/order: settings.json is one file for the whole server). Single-user mode and
* admins answer false. Read at call time, because `window.__codemanUser` resolves after page
* load (admin-ui.js fetches /api/me). Also true once the server has refused one of these
* writes as admin-only on this page (_noteSettingsWriteRefusal): before /api/me answers, and
* after it failed (admin-ui.js then falls back to a single-user admin), the role alone reads
* as writable. Other modules call it as
* `this._settingsReadOnly?.()`, so a harness that loads one of them without this file reads
* it as writable, which is what an admin gets.
*/
_settingsReadOnly() {
if (this._settingsWriteForbidden === true) return true;
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
},
/**
* Whether `res` (a settings write's response) is the multi-user admin-only refusal: 403 with
* errorCode FORBIDDEN. The tunnel's no-password refusal is a 403 too, with another code, and
* is not this. When it is, the page reads as read-only from now on (_settingsReadOnly), so a
* write sent before the role was known is the last one. Reads a clone, so the caller can
* still read the body.
* @returns {Promise<boolean>}
*/
async _noteSettingsWriteRefusal(res) {
if (!res || res.status !== 403) return false;
let body = null;
try {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch {
return false;
}
if (body?.errorCode !== 'FORBIDDEN') return false;
this._settingsWriteForbidden = true;
return true;
},
/**
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
* device under their name instead (_rememberOwnSetting) and put back over the server's on every
* page load (_applyOwnSettings): loadAppSettingsFromServer() writes the server's values into
* `codeman_runMode` and `codeman-respawn-presets` each time, which used to drop them on reload.
*/
_ownSettingsKey() {
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return `codeman:own-settings:${me.username || ''}`;
},
_readOwnSettings() {
try {
const own = JSON.parse(localStorage.getItem(this._ownSettingsKey()) || '{}');
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
} catch {
return {};
}
},
/** Keep a non-admin's own pick of a server-wide `key` on this device (see _ownSettingsKey). */
_rememberOwnSetting(key, value) {
try {
const own = this._readOwnSettings();
own[key] = value;
localStorage.setItem(this._ownSettingsKey(), JSON.stringify(own));
} catch {
/* storage unavailable: the pick lasts until the next page load */
}
},
/**
* A non-admin's own run mode and respawn presets win over the server's (see _ownSettingsKey).
* Called after the server's values are applied at load, and again on `codeman:me`, because the
* role can resolve either side of that load. A no-op for admins and single-user mode.
*/
_applyOwnSettings() {
if (!this._settingsReadOnly()) return;
const own = this._readOwnSettings();
if (Array.isArray(own.respawnPresets)) {
this._serverRespawnPresets = own.respawnPresets;
try { localStorage.setItem('codeman-respawn-presets', JSON.stringify(own.respawnPresets)); } catch {}
}
if (typeof own.runMode === 'string' && own.runMode) {
this.runMode = own.runMode;
try { localStorage.setItem('codeman_runMode', own.runMode); } catch {}
this._applyRunMode?.();
}
},
/**
* What the App Settings form showed when it opened, read the way saveAppSettings() reads it
* (its one-shot `_readSettingsFormOnce` form read), so a non-admin's save can tell whether the
* user changed a SYNCED setting. The stored blob cannot answer that: a key no admin has ever
* saved is absent from it while the form shows the default, which would read as a change on
* every save. Taken only for a non-admin; nobody else's save asks for it.
*/
_captureSettingsFormBaseline() {
this._settingsFormBaseline = null;
if (!this._settingsReadOnly()) return;
this._readSettingsFormOnce = true;
let pending;
try {
pending = this.saveAppSettings();
} finally {
// Consumed by the read itself; cleared here too in case it threw before reaching it.
this._readSettingsFormOnce = false;
}
Promise.resolve(pending)
.then((form) => {
this._settingsFormBaseline = form && typeof form === 'object' ? form : null;
})
.catch(() => {});
},
/**
* A non-admin's save in multi-user mode (saveAppSettings): put every SYNCED key of `settings`
* back to the value this device last had from the server (`prev`; deleted when it had none),
* and answer whether the form changed one since it opened. Synced means outside the per-device
* `displayKeys` merge policy of loadAppSettingsFromServer() (_displaySettingKeySet(), the one
* list both read): those are the keys the server's settings.json overwrites on every page load.
* A per-device key is left exactly as the user set it.
* @returns {boolean} true when the user changed at least one synced setting
*/
_keepServerSyncedSettings(settings, prev) {
const displayKeys = this._displaySettingKeySet();
// Without a baseline (the form was read before the role was known) the stored blob stands
// in, and a key it lacks is not counted: no toast is better than one on every save.
const baseline = this._settingsFormBaseline || prev || {};
let changed = false;
for (const key of Object.keys(settings)) {
// shortcutOverrides is carried over from `prev` already and never comes from the form.
if (displayKeys.has(key) || key === 'shortcutOverrides') continue;
if (key in baseline && JSON.stringify(settings[key]) !== JSON.stringify(baseline[key])) changed = true;
if (prev && key in prev) settings[key] = prev[key];
else delete settings[key];
}
return changed;
},
/**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that
@@ -3021,10 +3265,12 @@ Object.assign(CodemanApp.prototype, {
} catch (err) {
console.warn('Failed to load model config:', err);
}
// What the form shows now, so a non-admin's save can tell whether it was changed.
this._modelConfigBaseline = JSON.stringify(this._modelConfigFromSettingsForm());
},
// Save model configuration from settings modal to server
async saveModelConfigFromSettings() {
/** The model configuration as the settings modal's form shows it (the PUT body). */
_modelConfigFromSettingsForm() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore');
@@ -3038,11 +3284,26 @@ Object.assign(CodemanApp.prototype, {
if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
return {
defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
},
/**
* Save model configuration from settings modal to server.
* @returns {Promise<boolean|undefined>} true only when a non-admin in multi-user mode changed it:
* the config is a key of the server-wide settings.json and its PUT is admin-only (403), so it is
* not sent, and saveAppSettings() folds this into its one admin-only toast
*/
async saveModelConfigFromSettings() {
const config = this._modelConfigFromSettingsForm();
if (this._settingsReadOnly()) {
const baseline = this._modelConfigBaseline;
return typeof baseline === 'string' && JSON.stringify(config) !== baseline;
}
try {
await fetch('/api/execution/model-config', {
@@ -4327,6 +4588,37 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The per-device DISPLAY keys. They are device-specific (mobile vs desktop) and must not be
* synced from the server, to avoid overriding mobile defaults: loadAppSettingsFromServer()
* seeds one from the server only when this device has no value for it. A non-admin's save in
* multi-user mode reads the same split (_keepServerSyncedSettings()), so the list lives here,
* once, and is there even when GET /api/settings failed at load.
* NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
* are NOT display keys: they control server-side behavior and must sync from server.
*/
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
return displayKeys;
},
async loadAppSettingsFromServer(settingsPromise = null) {
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
@@ -4352,28 +4644,9 @@ Object.assign(CodemanApp.prototype, {
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
// Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
// the server's stored value here so it is NEVER seeded into a device that
@@ -4424,14 +4697,17 @@ Object.assign(CodemanApp.prototype, {
this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else {
// Migration: push existing localStorage presets to server
} else if (!this._settingsReadOnly()) {
// Migration: push existing localStorage presets to server. Not for a non-admin in
// multi-user mode: the presets are a server-wide setting there (admin-only PUT).
const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) {
const parsed = JSON.parse(localPresets);
if (parsed.length > 0) {
this._serverRespawnPresets = parsed;
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
this._apiPut('/api/settings', { respawnPresets: parsed })
.then((res) => this._noteSettingsWriteRefusal(res))
.catch(() => {});
}
}
}
@@ -4443,6 +4719,10 @@ Object.assign(CodemanApp.prototype, {
this._applyRunMode();
}
// Multi-user non-admin: their own run mode and presets, kept on this device, win over
// the server's just applied (no-op otherwise; `codeman:me` below covers a late role).
this._applyOwnSettings();
return merged;
}
} catch (err) {
@@ -4719,6 +4999,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
// Refuse a chord something else already owns: the registry dispatches the first match,
// so a duplicate would silently take over (or lose to) the other action.
const taken = this.findShortcutConflict(e, shortcutId);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
@@ -4735,7 +5023,70 @@ Object.assign(CodemanApp.prototype, {
this.renderShortcutSettingsList();
},
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
findShortcutConflict(e, shortcutId) {
// Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a
// rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index
// (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and
// Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler.
if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
return 'Switch to Tab N';
}
// (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.)
if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline';
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste';
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return '';
const other = this.getShortcutRegistry().find(
(shortcut) =>
shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut)
);
return other ? other.label : '';
},
/**
* Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a
* binding comes back without a capture (re-enable, Reset), where the chord may have been
* given to another action in the meantime.
*/
findShortcutBindingConflict(shortcut) {
for (const binding of shortcut?.bindings || []) {
const mods = binding.modifiers || [];
const taken = this.findShortcutConflict(
{
ctrlKey: mods.includes('ctrl'),
metaKey: mods.includes('meta'),
shiftKey: mods.includes('shift'),
altKey: mods.includes('alt'),
key: binding.key || '',
code: binding.code || '',
},
shortcut.id
);
if (taken) return taken;
}
return '';
},
describeShortcutEvent(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.metaKey) parts.push('Meta');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code);
return parts.join('+');
},
resetShortcutOverride(shortcutId) {
// Reset puts the default chord back (and re-enables the row): refuse when another action
// has taken that chord since, instead of leaving two actions on one key.
const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null;
const taken = fallback ? this.findShortcutBindingConflict(fallback) : '';
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`The default chord is already used by ${taken}`, 'error');
return;
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
@@ -4745,6 +5096,17 @@ Object.assign(CodemanApp.prototype, {
},
toggleShortcutEnabled(shortcutId, enabled) {
if (enabled) {
// Re-enabling brings the row's chord back into play: leave it disabled when another action
// has been bound to that chord while it was off.
const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId);
const taken = this.findShortcutBindingConflict(shortcut);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`Its chord is already used by ${taken}`, 'error');
return;
}
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
@@ -4777,6 +5139,9 @@ Object.assign(CodemanApp.prototype, {
// all, and a module-level statement that throws there fails the whole file's
// evaluation, not just this feature.
document.addEventListener?.('codeman:me', () => {
// A non-admin's own run mode and respawn presets (multi-user mode) win over the server's,
// which the settings load may already have applied before the role was known.
window.app?._applyOwnSettings?.();
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
+246
View File
@@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* Create New Case and Worktrees share the row, half each. */
display: flex;
gap: 8px;
}
.btn-case-create-mobile {
@@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
border-top: 1px solid var(--border);
}
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
.close-option-worktree {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.close-option-worktree[hidden] {
display: none;
}
.close-option-worktree-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-dim);
padding: 0 0.25rem;
cursor: pointer;
}
.close-option-worktree-branch input {
margin-top: 0.1rem;
}
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
dialog needs a primary action that reads as one on every skin. */
.modal-content.worktree-modal {
max-width: 640px;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.worktree-modal > .modal-body {
overflow-y: auto;
min-height: 0;
}
.worktree-modal-case {
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
color: var(--text-dim);
}
.worktree-intro {
margin: 0 0 0.6rem;
font-size: 0.72rem;
line-height: 1.45;
}
.worktree-status {
font-size: 0.78rem;
color: var(--text-dim);
margin-bottom: 0.6rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.worktree-status[hidden],
.worktree-form-error[hidden] {
display: none;
}
.worktree-status-error,
.worktree-form-error {
color: var(--red);
}
.worktree-form-error {
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 0.25rem;
}
.worktree-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.worktree-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.65rem;
background: var(--control-bg);
}
.worktree-row--managed {
border-color: var(--border-light);
}
.worktree-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.45rem;
}
.worktree-row-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--text);
overflow-wrap: anywhere;
}
.worktree-badge {
font-size: 0.66rem;
line-height: 1.5;
padding: 0 0.4rem;
border-radius: 999px;
border: 1px solid var(--border-light);
color: var(--text-dim);
white-space: nowrap;
}
.worktree-badge-branch {
font-family: 'SF Mono', Monaco, monospace;
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.35);
white-space: normal;
overflow-wrap: anywhere;
}
.worktree-badge-dim {
color: var(--text-muted);
}
.worktree-badge-warn {
color: var(--yellow);
border-color: var(--yellow);
}
.worktree-badge-live {
color: var(--green);
border-color: var(--green);
}
.worktree-row-actions {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.worktree-row-path {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.worktree-empty {
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.25rem 0.1rem;
}
.worktree-remove-confirm {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-light);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.worktree-remove-note {
margin: 0;
font-size: 0.72rem;
color: var(--text-dim);
}
.worktree-remove-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-dim);
cursor: pointer;
}
.worktree-remove-error {
margin: 0;
font-size: 0.72rem;
color: var(--red);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: rgba(224, 99, 99, 0.08);
border-radius: 6px;
padding: 0.4rem 0.5rem;
}
.worktree-remove-buttons {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.worktree-mode-row {
max-width: 320px;
}
.worktree-new {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem 0.4rem;
margin: 0;
min-width: 0;
}
.worktree-new > legend {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
padding: 0 0.3rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.worktree-advanced {
margin-bottom: 0.5rem;
}
.worktree-advanced > summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.wt-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.85rem;
border-radius: var(--btn-radius);
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text);
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.wt-btn:hover:not(:disabled) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
}
.wt-btn:disabled {
opacity: 0.5;
cursor: default;
}
.wt-btn-sm {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
}
.wt-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
.wt-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.wt-btn-danger {
color: var(--red);
border-color: rgba(224, 99, 99, 0.45);
}
.wt-btn-danger:hover:not(:disabled) {
background: rgba(224, 99, 99, 0.12);
border-color: var(--red);
}
.modal-session-name {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
+1
View File
@@ -327,6 +327,7 @@
) {
return false;
}
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
+3
View File
@@ -749,6 +749,9 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// A chord the user rebound to an app action (Settings -> Shortcuts) must not also type into the PTY.
if (ev.type === 'keydown' && this.isUserBoundShortcutEvent?.(ev)) return false;
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1.42.0",
"highlights": [
"Git worktrees for a case (#625)",
"Codex sessions turn red when Codex asks you something (#624)",
"Prompt uploads take documents (#621)",
"Previous and Next Session are rebindable (#617)",
"A note after each update (#618)",
"Multi-user mode: only admins change server-wide settings",
"Codex submits long prompts (#628)",
"New Claude sessions in an untrusted folder (#634, #635)"
]
}
+13 -9
View File
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,20 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
// not measured, so '1'/Esc would be a blind keystroke into a foreign
// composer. The item still raises the red alert and clears on the harness's
// own working/stop reports; answering happens in the terminal.
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
// the harness's own working/stop reports, and a dialog read off the screen of a
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
// screen and was read from text the agent writes, so it must never steer a key.
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
// cannot have pending approvals" would be false about the item the caller holds.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
);
}
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+8 -1
View File
@@ -60,6 +60,7 @@ import {
isAdmin,
isWorkingDirAllowed,
ownerFor,
requireAdmin,
resolveCasesDir,
SETTINGS_PATH,
validatePathWithinBase,
@@ -1665,7 +1666,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
// publish their case names to every user through GET /api/settings). Admin-only in
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
// storage. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
+1
View File
@@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
+17 -11
View File
@@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
isAdmin,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
@@ -463,20 +464,25 @@ export function registerRalphRoutes(
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
// user through GET /api/settings, so a non-admin's case name is not written there in
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
// in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* ignore */
/* non-critical */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
+203 -57
View File
@@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { finished } from 'node:stream/promises';
import { randomBytes, randomUUID } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
import { performance } from 'node:perf_hooks';
import {
ApiErrorCode,
@@ -181,6 +182,7 @@ import {
readDockerHosts,
toSessionDocker,
} from '../../docker-hosts.js';
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
@@ -323,18 +325,36 @@ export function stripInkRedrawBloat(buffer: string): string {
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
* The text half of the document allowlist: these are judged on their encoding
* (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
*/
const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
/**
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature (a text file is instead required to be
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
* header) and against simple extension-only spoofing — both the multipart
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
* Fail-closed: an extension this function does not know is never a match.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (TEXT_UPLOAD_EXTS.has(ext)) return data.length > 0 && !data.includes(0) && isUtf8(data);
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.pdf':
// "%PDF-"
return u32be(0) === 0x25504446 && data[4] === 0x2d;
case '.docx':
case '.pptx':
case '.xlsx':
case '.zip':
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
return u32be(0) === 0x504b0304;
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
@@ -428,11 +448,27 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
return uploadDir;
}
/**
* The part of an uploaded document's name that survives into the saved name,
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
* result is capped at 40 chars; nothing in it can be a path separator or a
* second extension that matters (the allowlisted extension is appended after).
*/
function sanitizeUploadStem(filename: string): string {
const stem = basename(filename).replace(/\.[^.]*$/, '');
return stem
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 40);
}
/** Open a fresh, uniquely named paste file for writing. */
async function openPasteFile(imageDir: string, ext: string) {
async function openPasteFile(imageDir: string, ext: string, stem = '') {
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
@@ -678,6 +714,27 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
return signalForStatus(session.status);
}
/**
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
* history, but this one is a level the server is still looking at, so a caller that
* asks after the dialog appeared is answered at once instead of waiting out its timeout
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
* session's current state.
*/
function currentSignalForWait(
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
dead: boolean,
until: readonly WaitSignal[]
): WaitSignal | null {
const signal = currentSignalFor(session, dead);
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked')) return 'blocked';
return signal;
}
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
//
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -1998,7 +2055,9 @@ export function registerSessionRoutes(
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
// delivery raised is a level, and a retry asking for `blocked` must find it.
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
@@ -2014,7 +2073,7 @@ export function registerSessionRoutes(
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
// (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
// Every write here is `fromUser`: this route carries a person's prompt, or an
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
// approvals write through the session directly and never say so).
@@ -2037,7 +2096,7 @@ export function registerSessionRoutes(
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
// ask for it: written straight into the pane it arrives as one burst, and Claude
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
// raw write for a caller that really wants it.
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
@@ -2182,7 +2241,7 @@ export function registerSessionRoutes(
abortSignal: abort.signal,
requireTransition: query.fresh === '1' || query.fresh === 'true',
// Read BEFORE awaiting: this is the state the caller is asking about.
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
});
return {
@@ -3675,6 +3734,7 @@ export function registerSessionRoutes(
agentOrigin,
customModel,
tabGroupId,
worktree,
} = parseBody(QuickStartSchema, req.body);
// Resolved ONCE here: the same value labels a case directory this request creates
@@ -3708,6 +3768,16 @@ export function registerSessionRoutes(
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
);
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
// container is touched, since neither branch below could honour it.
if (worktree && (remoteCase || dockerCase)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
remoteCase
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write'
);
}
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
@@ -3949,6 +4019,27 @@ export function registerSessionRoutes(
);
}
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
// one, every path component a real directory (src/git-worktrees.ts).
let workspacePath = resolvedCasePath;
if (worktree) {
if (localCaseState === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
}
if (localCaseState !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
}
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
});
if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message);
workspacePath = managed.path;
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (localCaseState === 'absent') {
@@ -4007,9 +4098,9 @@ export function registerSessionRoutes(
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (getCli(mode)?.capabilities.hooks === 'always') {
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(workspacePath).catch(() => {});
}
}
@@ -4021,7 +4112,7 @@ export function registerSessionRoutes(
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
await injectAgentSkill(workspacePath, quickStartSkillDir);
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
@@ -4054,14 +4145,14 @@ export function registerSessionRoutes(
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
await updateCaseModel(workspacePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
@@ -4117,7 +4208,7 @@ export function registerSessionRoutes(
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4223,7 +4314,7 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
resolvedCasePath
workspacePath
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
@@ -4264,7 +4355,7 @@ export function registerSessionRoutes(
const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
id: qsCustomModelSessionId,
workingDir: resolvedCasePath,
workingDir: workspacePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
@@ -4322,7 +4413,7 @@ export function registerSessionRoutes(
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
autoConfigureRalph(session, workspacePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -4386,34 +4477,40 @@ export function registerSessionRoutes(
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
console.warn('[Server] Failed to prepare settings update:', err);
}
}
return {
sessionId: session.id,
casePath: resolvedCasePath,
caseName,
...(worktree ? { worktree, workingDir: workspacePath } : {}),
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
@@ -5350,6 +5447,12 @@ export function registerSessionRoutes(
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
// would upload but not preview, and a phone's camera roll produces neither.
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
// Documents a user hands an agent to read: the office formats plus the text
// family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
// EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
// to read, not a thing to run or serve. Each is judged on its bytes by
// imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
const VIDEO_MIME_EXT: Record<string, string> = {
mp4: '.mp4',
quicktime: '.mov',
@@ -5359,6 +5462,19 @@ export function registerSessionRoutes(
};
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
async function writeBufferedUpload(workingDir: string, ext: string, bytes: Buffer, stem = '') {
const uploadDir = await ensureUploadDir(workingDir);
if (!uploadDir) return null;
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
try {
await fh.writeFile(bytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
}
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
@@ -5492,13 +5608,13 @@ export function registerSessionRoutes(
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
);
}
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const uploadDir = await ensureUploadDir(session.workingDir);
if (!uploadDir) {
await finished(part.file.resume()).catch(() => {});
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
// Written as it arrives: a phone video is far too large to buffer the way an
// image is. The container is judged on the first 12 bytes as soon as they are
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
@@ -5557,12 +5673,41 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
// the image path whatever the name says, a document extension (the MIME a
// phone gives a .md is anyone's guess) takes the document branch, anything
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
const imageMime = /^image\//i.test(part.mimetype || '');
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
if (!imageMagicMatchesExt(imageBytes, origExt)) {
// The first bytes of a text file are user content, so for text the log
// names the failure and never the bytes; a signature is safe to print.
const detail = TEXT_UPLOAD_EXTS.has(origExt)
? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
console.warn(
`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
}
const saved = await writeBufferedUpload(
session.workingDir,
origExt,
imageBytes,
sanitizeUploadStem(part.filename)
);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
return saved;
}
// Determine extension from filename or Content-Type.
let ext = '.png';
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
if (mimeMatch) {
const map: Record<string, string> = {
@@ -5603,6 +5748,15 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
}
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
// A foreign extension with no image MIME (a script, an HTML page, a video
// without its flag) was never going to be an image: say so, rather than
// the ".png" the default extension would name. Decided only HERE, after
// the sniff, so anything the image path ever accepted by its bytes (a
// HEIC under a generic name) still is.
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
}
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
// serve back with image/png MIME. Log the real header so format
// mismatches can be pinned down without a reproduce-and-guess loop. The
@@ -5614,20 +5768,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
return saved;
});
// Returned so the server can own the registry's LIFETIME (drop state when a session is
+10 -5
View File
@@ -51,12 +51,17 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// Only a CLI that has a statusline exporter at all speaks for its own model and
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
const session = ctx.sessions.get(sessionId);
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
// broadcasts only a change.
if (session && reportsStatusLine) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
@@ -68,7 +73,7 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
+81 -12
View File
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -144,6 +144,28 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
return `http://localhost:${port}`;
}
/**
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings;
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
delete voiceOut.apiKey;
out.voiceSettings = voiceOut;
}
return out;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
@@ -163,12 +185,17 @@ export function registerSystemRoutes(
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
app.get('/api/tunnel/info', async () => {
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
const sseClients = ctx.getSseClientCount();
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
// Multi-user mode: a non-admin is shown only their own logins (address and browser of
// every other user's sessions are not theirs to read), matching what their Revoke All
// can revoke (POST /api/auth/revoke). null = everyone's (admin, single-user mode).
const onlyUser = isMultiUserMode() && !isAdmin(req) ? getAuthUser(req).username : null;
if (ctx.authSessions) {
for (const [, record] of ctx.authSessions) {
if (onlyUser !== null && record.username !== onlyUser) continue;
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
}
}
@@ -300,7 +327,10 @@ export function registerSystemRoutes(
// ========== QR Regeneration ==========
app.post('/api/tunnel/qr/regenerate', async () => {
app.post('/api/tunnel/qr/regenerate', async (req, reply) => {
// Clears EVERY outstanding QR login code (each user's minted one too) and rotates the
// server's token: admin-only in multi-user mode, like the other tunnel controls.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
ctx.tunnelManager.regenerateQrToken();
return {};
});
@@ -309,6 +339,19 @@ export function registerSystemRoutes(
app.post('/api/auth/revoke', async (req) => {
const result = RevokeSessionSchema.safeParse(req.body);
// Multi-user mode: a NON-ADMIN revokes only their own login sessions. The bare form
// below clears every session on the server, so for them it used to sign out every
// user, admins included; a named token is revoked only when it is one of theirs.
if (isMultiUserMode() && !isAdmin(req)) {
const username = getAuthUser(req).username;
const token = result.success ? result.data.sessionToken : undefined;
if (token) {
if (ctx.authSessions?.peek(token)?.username === username) ctx.authSessions?.delete(token);
} else {
revokeUserSessions(ctx.authSessions, username);
}
return {};
}
if (result.success && result.data.sessionToken) {
ctx.authSessions?.delete(result.data.sessionToken);
} else {
@@ -327,6 +370,8 @@ export function registerSystemRoutes(
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// Launches a browser window on the SERVER's desktop: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
@@ -379,7 +424,9 @@ export function registerSystemRoutes(
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
app.post('/api/system/update', async (req, reply) => {
// Updates and restarts the server under every user: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const result = await startUpdate();
if (result.ok) {
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
@@ -617,13 +664,16 @@ export function registerSystemRoutes(
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
// PATH leads with the directory `dsh` itself resolved to: CLI management installs
// `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH
// usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it.
env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') },
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
@@ -718,7 +768,10 @@ export function registerSystemRoutes(
// ========== State & Lifecycle ==========
app.post('/api/cleanup-state', async () => {
app.post('/api/cleanup-state', async (req, reply) => {
// Drops every user's stale session records (and their tab-layout entries): admin-only
// in multi-user mode, like the other server-wide maintenance operations.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const activeSessionIds = new Set(ctx.sessions.keys());
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
@@ -866,7 +919,10 @@ export function registerSystemRoutes(
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
app.put('/api/config', async (req, reply) => {
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { config: ctx.store.getConfig() };
@@ -935,7 +991,7 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
@@ -947,10 +1003,20 @@ export function registerSystemRoutes(
// `workspaceHooksEnabled` means ON, and the client resolves its own display
// default through planUsageChipEnabled(). Pinned by
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
return readJsonConfig(SETTINGS_PATH, 'settings', {});
const settings = await readJsonConfig<unknown>(SETTINGS_PATH, 'settings', {});
// Readable by every signed-in user, but a non-admin in multi-user mode gets it without
// the admin's credential and case names (settingsForNonAdmin). isAdmin is always true in
// single-user mode, so that answer is the file as it is.
return isAdmin(req) ? settings : settingsForNonAdmin(settings);
});
app.put('/api/settings', async (req) => {
app.put('/api/settings', async (req, reply) => {
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
@@ -1089,7 +1155,10 @@ export function registerSystemRoutes(
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
app.put('/api/execution/model-config', async (req, reply) => {
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
+290
View File
@@ -0,0 +1,290 @@
/**
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
*
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
* Codeman manages, and the live sessions in each
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
* new branch (`{ name, branch?, base? }`)
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
* while a live session runs inside; optional
* `?deleteBranch=true` is `git branch -d`
*
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
* `POST /api/sessions` with the worktree path as `workingDir`.
*
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
*
* Statuses are set explicitly from the error code, so the route answers the same with or without
* the server's envelope hook.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
import { readDockerCases } from '../../docker-hosts.js';
import { readRemoteCases } from '../../remote-hosts.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { listClis } from '../../config/cli-registry/registry.js';
import { agentSkillDir } from '../../hooks-config.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import {
createCaseWorktree,
isPathWithin,
listCaseWorktrees,
managedWorktreesDir,
removeCaseWorktree,
resolveCaseRepository,
respellUnder,
worktreeNameError,
type CaseRepository,
type CaseWorktree,
type CreatedWorktree,
type RemovedWorktree,
type WorktreeFailure,
type WorktreeGitRunner,
} from '../../git-worktrees.js';
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
import {
canAccessOwned,
getAuthUser,
isAdmin,
isWorkingDirAllowed,
parseBody,
readJsonConfig,
resolveCasesDir,
validatePathWithinBase,
} from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
*/
const registryAgentSkillDirs = (): string[] => {
const dirs = new Set<string>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
};
/** A live session in a worktree, as the list reports it to a caller who may see it. */
export interface WorktreeSessionRef {
id: string;
name: string;
}
/** One row of `GET /api/cases/:name/worktrees`. */
export interface CaseWorktreeInfo extends CaseWorktree {
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
sessionCount: number;
/** The ones the caller may see (all of them outside multi-user mode). */
sessions: WorktreeSessionRef[];
}
export interface CaseWorktreeList {
caseName: string;
repoRoot: string;
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
worktreesDir: string;
worktrees: CaseWorktreeInfo[];
truncated: boolean;
}
type Fail = ApiResponse<never>;
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
reply.code(httpStatusForErrorCode(code));
return createErrorResponse(code, message);
}
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
return failWith(reply, ApiErrorCode[failure.code], failure.message);
}
/**
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
* the same case here as it does to the Run button.
*/
async function resolveLocalCaseFolder(
req: FastifyRequest,
name: string
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
if (!SAFE_CASE_NAME.test(name)) {
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
}
const user = getAuthUser(req);
const configDir = getDataDir();
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
// case falls through to the caller's own local case instead of shadowing it.
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
};
}
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message:
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
};
}
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
// Linked cases carry no owner: only admins resolve them in multi-user mode.
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
if (!isWorkingDirAllowed(user, casePath)) {
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
}
const kind = await probePathKind(casePath, { pastCap: true });
if (kind === 'unknown') {
return {
ok: false,
code: ApiErrorCode.OPERATION_FAILED,
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
};
}
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
if (kind !== 'directory')
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
return { ok: true, casePath };
}
/**
* Live local sessions, with the folder each runs in as the case's repository spells it
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
* have no local folder.
*/
function liveLocalSessions(
ctx: SessionPort,
casePath: string,
repoRoot: string
): Array<{ id: string; name: string; owner?: string; dir: string }> {
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
for (const session of ctx.sessions.values()) {
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
const dir = respellUnder(session.workingDir, casePath, repoRoot);
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
}
return out;
}
export function registerWorktreeRoutes(
app: FastifyInstance,
ctx: SessionPort,
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
): void {
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
/**
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
*/
async function caseRepository(
req: FastifyRequest,
reply: FastifyReply,
name: string
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
const folder = await resolveLocalCaseFolder(req, name);
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
const dockerRoots = await dockerWorkspaces();
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
}
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
const { name } = req.params as { name: string };
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const { repo, casePath } = resolved;
const listed = await listCaseWorktrees(repo, { git: deps.git });
if (!listed.ok) return failFrom(reply, listed);
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
// managed worktree is not also counted against the main checkout that contains it.
const user = getAuthUser(req);
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
let best: CaseWorktreeInfo | undefined;
for (const row of rows) {
if (!isPathWithin(session.dir, row.path)) continue;
if (!best || row.path.length > best.path.length) best = row;
}
if (!best) continue;
best.sessionCount += 1;
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
}
return {
success: true,
data: {
caseName: name,
repoRoot: repo.repoRoot,
worktreesDir: managedWorktreesDir(repo.repoRoot),
worktrees: rows,
truncated: listed.truncated,
},
};
});
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
const { name } = req.params as { name: string };
const body = parseBody(CreateWorktreeSchema, req.body);
const nameError = worktreeNameError(body.name);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const created = await createCaseWorktree(resolved.repo, body, {
git: deps.git,
agentSkillDirs: registryAgentSkillDirs(),
});
if (!created.ok) return failFrom(reply, created);
reply.code(201);
return { success: true, data: { worktree: created.worktree } };
});
app.delete(
'/api/cases/:name/worktrees/:worktree',
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
const { name, worktree } = req.params as { name: string; worktree: string };
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
const nameError = worktreeNameError(worktree);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const removed = await removeCaseWorktree(
resolved.repo,
{
name: worktree,
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
// Any owner's session blocks: removing the folder under a running agent is the damage
// this refusal exists to prevent, whoever started it.
isInUse: (path) =>
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
},
// The worktree folder itself must not be (or hold) a Docker case workspace either.
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
);
if (!removed.ok) return failFrom(reply, removed);
return { success: true, data: { removed: removed.removed } };
}
);
}
+45 -1
View File
@@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause: z
.object({
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
})
.nullish(),
})
.nullish(),
})
@@ -759,6 +768,34 @@ export const ClonePreflightSchema = z.object({
repository: z.string().min(1).max(2048),
});
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
/**
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
*/
const worktreeNameSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".');
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
export const CreateWorktreeSchema = z.object({
name: worktreeNameSchema,
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
branch: z.string().min(1).max(200).optional(),
/** Start point (branch, tag or commit); default the case's current HEAD. */
base: z.string().min(1).max(200).optional(),
});
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
export const RemoveWorktreeQuerySchema = z.object({
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch: z.enum(['true', 'false', '1', '0']).optional(),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
@@ -1139,6 +1176,13 @@ export const QuickStartSchema = z.object({
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
/**
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
* of the case's repository; it is never created here.
*/
worktree: worktreeNameSchema.optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
*
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
* and the one CLI Codeman launches with its approvals on by default) declares
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
* that edge into exactly what a Claude `permission_prompt` hook produces in
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
* second code path:
*
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
* `hook:permission_prompt` broadcast and the push/webhook.
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
*
* Rules it keeps:
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
* card and the overview strip say "answer in the terminal". The dialog is read
* off a screen the agent writes most of, so a forged match must cost a false alert and
* never a keystroke. Codeman does not answer these dialogs.
* - The item gets no `capture` callback, only the frame it was raised from as context:
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
* - Only the item this module opened is resolved on clear, never a hook-raised one.
*
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
* @consumedby web/session-listener-wiring (blockedChanged)
* @module web/screen-blocked
*/
import { getCli } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { approvalInbox } from './approval-inbox.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
import { SseEvent } from './sse-events.js';
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
export const SCREEN_BLOCKED_SOURCE = 'screen';
/** The facts about a session this module reads (structural, so tests need no `Session`). */
export interface ScreenBlockedSession {
id: string;
name: string;
mode: SessionMode;
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}
/** What the server lends it: the same channels the hook route uses. */
export interface ScreenBlockedDeps {
broadcast(event: string, data: unknown): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
getRunSummaryTracker?(
sessionId: string
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
}
/**
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
* never an id branch), and from nothing on the screen: the dialog's wording is the
* agent's to choose (a question's text, a command preview), so none of it reaches a push
* or a webhook. The approval card shows the frame itself, escaped.
*/
export function screenBlockedMessage(mode: SessionMode): string {
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
}
/**
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
* `permission_prompt` hook would.
*
* @param frame the capture the dialog was read from, kept as the card's context
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
*/
export function raiseScreenBlocked(
session: ScreenBlockedSession,
deps: ScreenBlockedDeps,
frame?: string | null
): string | null {
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
const message = screenBlockedMessage(session.mode);
// Before anything that can throw, like the hook route: a caller parked on `blocked`
// is the reason this exists for agents.
sessionWaits.notifySignal(session.id, 'blocked');
const item = approvalInbox.notePrompt({
sessionId: session.id,
sessionName: session.name,
kind: 'permission',
message,
answerInTerminal: true,
context: frame ?? undefined,
});
const data = { source: SCREEN_BLOCKED_SOURCE, message };
deps.broadcast(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
timestamp: Date.now(),
...data,
approvalId: item.id,
});
// Same ride-along as the hook route: the home screens rank the blocked group on the
// session's stamps, so the row moves without waiting for some unrelated event.
deps.broadcastSessionStateDebounced(session.id);
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
sessionName: session.name,
...data,
});
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
return item.id;
}
/**
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
*
* @param itemId what `raiseScreenBlocked()` returned
*/
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
if (!itemId) return;
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
+11
View File
@@ -112,6 +112,7 @@ import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -191,6 +192,7 @@ import {
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
@@ -1153,6 +1155,7 @@ export class WebServer extends EventEmitter {
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerWorktreeRoutes(this.app, ctx);
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
@@ -2835,6 +2838,14 @@ export class WebServer extends EventEmitter {
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
} else if (event === SseEvent.HookPermissionPrompt && data.source === SCREEN_BLOCKED_SOURCE && data.message) {
// A dialog read off a hook-less CLI's screen (web/screen-blocked.ts), whose message
// is built from the CLI's label, never from the screen. Keyed on the source so a
// Claude hook's own `message` keeps the body it always had; capped like the error
// text, since a hook poster holding the secret can claim the source too (as it can
// already claim any `tool_name` above).
body += body ? ' ' : '';
body += String(data.message).slice(0, 200);
}
return body;
}
+21
View File
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
import { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
blockedChanged: (blocked: boolean, frame: string | null) => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
@@ -98,6 +100,8 @@ interface SessionListenerDeps {
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
let screenBlockedItemId: string | null = null;
return {
// ─── Terminal Output ─────────────────────────────────────
@@ -276,6 +280,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
*/
blockedChanged: (blocked: boolean, frame: string | null) => {
if (blocked) {
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
} else {
clearScreenBlocked(session.id, screenBlockedItemId);
screenBlockedItemId = null;
}
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
@@ -519,6 +538,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('blockedChanged', refs.blockedChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
@@ -557,6 +577,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('blockedChanged', refs.blockedChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
+35 -14
View File
@@ -28,11 +28,17 @@
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
* `blocked` never fire for anything else: callers must be rejected up front rather
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
* `shell` is not an external CLI yet installs no hooks either.
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
* bridge reports the same two), so `stop` never fires for anything else: callers must
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
* keeps that rule in one place, and it is keyed on the MODE rather than on
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
* either.
*
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
*
* ## Ordering contract for the wiring
*
@@ -152,7 +158,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
* `stopped` and `error` both map to `exit` because both mean "not running", so a
* caller that does catch one of them does not block. `blocked` is deliberately
* underivable here, it exists only as a hook event until it becomes a real
* `SessionStatus` (see the deferred Part 3 in the plan).
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
* that one itself.
*/
export function signalForStatus(status: SessionStatus): WaitSignal | null {
switch (status) {
@@ -168,9 +176,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
}
}
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
@@ -266,6 +271,19 @@ export function sessionHookOptions(session: {
};
}
/**
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
*
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
*/
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
export interface ResolvedWaitSignals {
/** Signals to actually wait on. Empty when `error` is set. */
@@ -279,9 +297,9 @@ export interface ResolvedWaitSignals {
* every wait endpoint shares:
*
* 1. An unknown token is an ERROR, never a silent fallback to the default.
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
* never 400.
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
*
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
* not drift; the second-guessing that produces is worse than the duplication.
@@ -304,7 +322,9 @@ export function resolveWaitSignals(
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>();
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -324,7 +344,8 @@ export function resolveWaitSignals(
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
};
}
return { until: parsed.signals, error: null };
+7 -2
View File
@@ -26,7 +26,8 @@
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
@@ -365,7 +366,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
/** Claude Code hook: session idle, waiting for input. */
export const HookIdlePrompt = 'hook:idle_prompt' as const;
/** Claude Code hook: tool requesting permission. */
/**
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
*/
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
+24
View File
@@ -163,6 +163,30 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
expect(preamble).toBe(heredoc);
});
it("SKILL.md's shell survives Claude Code's skill-argument substitution unchanged", async () => {
// When Claude Code loads a skill WITH arguments (the Skill tool passes the user's request words),
// it rewrites the skill body before the model sees it. Measured on 2.1.300: every `$ARGUMENTS[N]`,
// `$ARGUMENTS` and bare `$N` (digits, then no word character) becomes the matching argument, 0-based,
// and a backslash before `$` is consumed as an escape. A bare `$1` in §0 arrived as
// `sessions/5/terminal` and `timeout=claude` for "start 5 claude workers", so an agent pasting §0
// wrote a broken preamble. `${1}` is the same bash and is never touched; keep it that way.
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
const substitute = (body: string, args: string[]): string =>
body
.replace(/(?<!\\)\\\$(?=\d|ARGUMENTS)/g, '$')
.replace(/\$ARGUMENTS\[(\d+)\]/g, (m, i: string) => args[Number(i)] ?? m)
.replace(/\$(\d+)(?!\w)/g, (m, i: string) => args[Number(i)] ?? m)
.replaceAll('$ARGUMENTS', args.join(' '));
const args = 'start 5 claude workers, one per TODO item'.split(/\s+/);
expect(substitute(skillMd, args)).toBe(skillMd);
// And the heredoc an agent pastes from the substituted body is still the seeded preamble.
const rendered = substitute(skillMd, args);
const start = rendered.indexOf("<<'PREAMBLE'\n") + "<<'PREAMBLE'\n".length;
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
expect(rendered.slice(start, rendered.indexOf('\nPREAMBLE\n', start) + 1)).toBe(preamble);
});
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
const prevXdg = process.env.XDG_CACHE_HOME;
const cacheDir = join(casePath, 'xdg-cache');
+57
View File
@@ -183,6 +183,63 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
+421
View File
@@ -0,0 +1,421 @@
/**
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
*
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
* to run the following command?" used to read as plainly idle, and one parked on a
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
* session reads it at every settle and on a timer while it is up, and the server raises
* exactly what a Claude `permission_prompt` hook raises, alert-only.
*
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
isBlockedScreen,
BLOCKED_TAIL_LINES,
BLOCKED_MAX_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_CLEAR_READS,
BLOCKED_QUIET_RECHECK_MS,
IDLE_SILENCE_MS,
PANE_PROBE_MIN_INTERVAL_MS,
} from '../src/session-activity.js';
import type { SessionMode } from '../src/types.js';
const DETECT = getCli('codex')!.capabilities.workDetect!;
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
const CODEX_WINDOW = DETECT.blockedLines!;
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
/** Every dialog codex was measured blocking on. */
const DIALOGS = [
'exec-approval.txt',
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
'exec-approval-w44.txt',
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
'edit-approval.txt',
'network-escalation-approval.txt',
'question.txt',
'question-option2-selected.txt',
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
'question-w60.txt',
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
'question-notes-typed.txt',
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
'trust-dialog-w40.txt',
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
'trust-dialog-back.txt',
];
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
const NOT_DIALOGS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
'review-popup.txt',
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
];
describe('codex blockedLine on live captures', () => {
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
expect(CODEX_BLOCKED).not.toBeNull();
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
});
it.each(DIALOGS)('reads %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(true);
});
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(false);
});
it('needs the whole window for the narrowest question, and no more than it declares', () => {
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
// that stops short of it reads nothing, which is the pre-field behaviour, never a
// false alert.
const narrow = fixture('question-w36.txt');
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
});
it('sees the question dialogs that the working line used to claim', () => {
// Why a blocked screen has to WIN over the working line in the probe: the question's
// own key hints end in `esc to interrupt`.
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
expect(blocked(fixture(name)), name).toBe(true);
}
});
});
describe('codex blockedLine against a screen the agent writes', () => {
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
// path vary), hint row.
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
`› ${typed}`,
'',
footer,
' ? for shortcuts',
];
const forgedDialog = [
'• Here is what the dialog looks like:',
'› 1. Yes, proceed (y)',
' 2. No, and tell Codex what to do differently (esc)',
'',
' Press enter to confirm or esc to cancel',
];
it('refuses a forged dialog in the transcript, whatever follows it', () => {
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
// While a prompt is typed the hint row goes away, so the forged hint sits only two
// rows above the end: the composer row (column 0, `›`) still breaks the run.
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
// Even with a lowercase model and a short path, the footer row would have to end in
// one of the hint's own closing words.
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
});
it('refuses the working view with the dialog quoted right above the status line', () => {
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
expect(blocked(screen)).toBe(false);
});
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
expect(
blocked(
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
)
).toBe(false);
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
});
it('stays linear on a hostile window', () => {
// Many option rows and long rows, ending in something that is almost the hint: the
// pattern must give up quickly, since it runs on a timer while a dialog is up.
const row = `› 1. ${'x'.repeat(300)}`;
const screen = [
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
` Press enter to confirm ${'or '.repeat(60)}`,
].join('\n');
const started = performance.now();
for (let i = 0; i < 50; i++) blocked(screen);
expect(performance.now() - started).toBeLessThan(250);
expect(blocked(screen)).toBe(false);
});
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
const dialog = fixture('exec-approval.txt');
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
});
it('answers false for an empty or unreadable capture', () => {
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
});
});
// ─── The session's blocked state ────────────────────────────────────────────
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* What the pane shows, and how often the session has read it: every read is a synchronous
* `tmux capture-pane` on the live server.
*/
type FakeScreen = { text: string | null; captures?: number };
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => {
screen.captures = (screen.captures ?? 0) + 1;
return screen.text;
},
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* The pane changes the only way a real one can: tmux redraws it to the attached client,
* which reaches the session as output. A cursor move and an erase, with no composer glyph
* in it, so it marks activity without arming the idle confirmation.
*/
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
screen.text = text;
feed(session, '\x1b[2;1H\x1b[K');
}
/** Output, then quiet: the moment the idle confirmation reads the screen. */
function settle(session: Session): void {
for (let i = 0; i < 3; i++) {
feed(session, CODEX_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
return changes;
}
describe('Session.screenBlocked', () => {
afterEach(() => {
vi.useRealTimers();
});
it('raises once when the pane settles on an approval, and reads the session idle', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
expect(changes).toHaveLength(1);
expect(changes[0].blocked).toBe(true);
// The frame it was read from rides along, as the approval card's context.
expect(changes[0].frame).toContain('Would you like to run the following command?');
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
settle(session);
expect(changes).toHaveLength(1);
});
it('calls a question dialog blocked and idle, not working', () => {
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
// field the probe called this pane busy for as long as the question stayed up.
vi.useFakeTimers();
const session = withFakePane({ text: fixture('question.txt') });
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
});
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
// what notices, and one read alone is not enough.
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
// Nothing keeps reading a screen with no dialog on it.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
expect(changes).toHaveLength(2);
});
it('does not flap on one read that misses a dialog still on screen', () => {
// A capture inside a full-frame repaint can come back without the footer. Clearing on
// it would raise again a moment later: a second alert, push and webhook for one dialog.
vi.useFakeTimers();
const dialog = fixture('exec-approval.txt');
const screen = { text: dialog as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
redraw(session, screen, dialog);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
expect(session.screenBlocked).toBe(true);
expect(changes).toHaveLength(1);
});
it('keeps its state through an unreadable capture, in either direction', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
settle(session);
// The pane moved, so the session looks, and tmux answers nothing it can read.
redraw(session, screen, null);
const before = screen.captures!;
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
expect(session.screenBlocked).toBe(true);
// ...and it is still watching: once the screen reads again without the dialog, it clears.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
// screen only through a repaint, which reaches the session as output, so a pane that
// drew nothing since the last reading that saw the dialog is still showing it.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
expect(session.screenBlocked).toBe(true);
const quietMs = 10 * 60_000;
const before = screen.captures!;
vi.advanceTimersByTime(quietMs);
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
// The answer repaints the pane, and the clear comes as fast as before.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
// tmux updates the pane before it sends this client the repaint, so a capture can see
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
// is right only while the last reading SAW the dialog: once one reading missed it,
// waiting for output could leave the alert up for the whole safety-net interval.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(false);
});
it('raises again for the next dialog after a clear', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
screen.text = fixture('edit-approval.txt');
settle(session);
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
});
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
vi.useFakeTimers();
for (const mode of ['claude', 'gemini'] as const) {
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked, mode).toBe(false);
expect(changes, mode).toHaveLength(0);
}
});
it('drops the state when a new pane starts, announcing it', () => {
vi.useFakeTimers();
const session = withFakePane({ text: fixture('trust-dialog.txt') });
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
(session as unknown as { _resetBuffers(): void })._resetBuffers();
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, afterEach } from 'vitest';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const root = resolve(import.meta.dirname, '..');
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function deepseekInstallCommand(platform: 'linux' | 'darwin'): string {
const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!;
return ds.discovery.install.command![platform]!;
}
/**
* Run the stock install command through `/bin/sh -c` (what CLI management does, `shell: true`)
* with a fake `npm` that records each call, on a PATH that holds only the fake bins. So this
* exercises the real string, not a copy of it.
*/
function runInstall(opts: { pnpmOnPath: boolean; npmFails?: boolean }): { status: number | null; npmCalls: string[] } {
const dir = mkdtempSync(join(tmpdir(), 'dsh-install-'));
dirs.push(dir);
const npmBin = join(dir, 'npm-bin');
const pnpmBin = join(dir, 'pnpm-bin');
mkdirSync(npmBin);
mkdirSync(pnpmBin);
const log = join(dir, 'npm.log');
writeFileSync(log, '');
writeFileSync(join(npmBin, 'npm'), '#!/bin/sh\necho "$*" >> "$NPM_LOG"\nexit "${NPM_EXIT:-0}"\n');
chmodSync(join(npmBin, 'npm'), 0o755);
writeFileSync(join(pnpmBin, 'pnpm'), '#!/bin/sh\nexit 0\n');
chmodSync(join(pnpmBin, 'pnpm'), 0o755);
const result = spawnSync('/bin/sh', ['-c', deepseekInstallCommand('linux')], {
env: {
PATH: opts.pnpmOnPath ? `${npmBin}:${pnpmBin}` : npmBin,
NPM_LOG: log,
NPM_EXIT: opts.npmFails ? '1' : '0',
},
encoding: 'utf-8',
});
const npmCalls = readFileSync(log, 'utf-8').split('\n').filter(Boolean);
return { status: result.status, npmCalls };
}
describe('DeepSeek install', () => {
it('is the same command on linux and darwin', () => {
expect(deepseekInstallCommand('darwin')).toBe(deepseekInstallCommand('linux'));
});
it('installs pnpm with dsh when none is on PATH, since `dsh plugin` spawns a literal pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh', 'install -g pnpm']);
});
it('leaves an existing pnpm alone (a corepack shim makes `npm install -g pnpm` fail with EEXIST)', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: true });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it('reports a failed dsh install and does not go on to pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false, npmFails: true });
expect(status).not.toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it("puts dsh's own directory first on the profile install's PATH", () => {
const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8');
expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/);
});
});
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Working (5s • esc to interrupt)
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6-Luna medium · /tmp/codex-scratch/repo · Request approval to create file Plan mode
? for shortcuts
@@ -0,0 +1,23 @@
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to
ask me which animal to write in it, with 3 options. Ask
only that one question.
■ Conversation interrupted - use /feedback if something went
wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to
cancel tab to add notes | enter to submit answer | esc
to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo Plan mode
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
■ Conversation interrupted - use /feedback if something went wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to cancel tab to add notes | enter to submit answer | esc to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo · Ask for animal choice Plan mode (shift+tab to cycle)
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more