mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
Compare commits
177
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f89718a7 | ||
|
|
ffaa5ee80c | ||
|
|
6aecc3b858 | ||
|
|
470cf79776 | ||
|
|
06c4c7da16 | ||
|
|
7917273188 | ||
|
|
b451b3851e | ||
|
|
6d6e7da481 | ||
|
|
52267f8617 | ||
|
|
36af183f97 | ||
|
|
c029cea620 | ||
|
|
b8038a592c | ||
|
|
ccd6df893f | ||
|
|
e082d8e438 | ||
|
|
fa53a5751e | ||
|
|
23d145b121 | ||
|
|
5724e0c8b4 | ||
|
|
9649b5019b | ||
|
|
ec2a036543 | ||
|
|
c197e9370b | ||
|
|
d887002ca8 | ||
|
|
574f3db58b | ||
|
|
17a976fa2e | ||
|
|
7064b3c1d5 | ||
|
|
01f403dc1b | ||
|
|
2cf37529e9 | ||
|
|
df398c5c68 | ||
|
|
d68a173a23 | ||
|
|
0ff57ce304 | ||
|
|
f39c66e4e8 | ||
|
|
4398dbfad0 | ||
|
|
c9a5fdab00 | ||
|
|
7616de13de | ||
|
|
af032fc81a | ||
|
|
41a10b159e | ||
|
|
e6b258fc44 | ||
|
|
98c6c1881d | ||
|
|
9240493c43 | ||
|
|
7cbce5bf6c | ||
|
|
3df113fc54 | ||
|
|
0ae39cdd94 | ||
|
|
45db24bacf | ||
|
|
4123d229f4 | ||
|
|
f776ad87b6 | ||
|
|
846c62fbf7 | ||
|
|
73c0bfccc4 | ||
|
|
3af1ff6fae | ||
|
|
988f111cd0 | ||
|
|
5f5de5827e | ||
|
|
0aba9f6ec8 | ||
|
|
61dbd97ba4 | ||
|
|
b5d8122ea7 | ||
|
|
57f7a77573 | ||
|
|
af4cc45cfd | ||
|
|
01eb8ef08a | ||
|
|
140ca35e2d | ||
|
|
17232b01f6 | ||
|
|
612c69d57a | ||
|
|
848ab48b0a | ||
|
|
0b106b03eb | ||
|
|
54c591c84d | ||
|
|
2eece4f8f9 | ||
|
|
4d165d3fb1 | ||
|
|
d5ffc22f4a | ||
|
|
c2dfc775a3 | ||
|
|
e439cf0ef3 | ||
|
|
1f4c390e12 | ||
|
|
714050fe8a | ||
|
|
dfd3df8289 | ||
|
|
a0fbd1d28d | ||
|
|
272b56d47b | ||
|
|
1645ef5f5c | ||
|
|
627b76739c | ||
|
|
83e39c40a1 | ||
|
|
fec0409315 | ||
|
|
b4954c14cd | ||
|
|
c9f47b095a | ||
|
|
47ac16d6ab | ||
|
|
6d147c1bf1 | ||
|
|
92921b9107 | ||
|
|
614c7e6cd5 | ||
|
|
7659ca8b44 | ||
|
|
61037082d1 | ||
|
|
5e27043bf7 | ||
|
|
ee1a155e2c | ||
|
|
2d96472dbe | ||
|
|
e71971cab4 | ||
|
|
e60b5a8a2c | ||
|
|
1d85909a06 | ||
|
|
e0542bb172 | ||
|
|
1da2fa2529 | ||
|
|
45ea2e1d32 | ||
|
|
f6aa50239f | ||
|
|
9676e90133 | ||
|
|
bf73a84732 | ||
|
|
8d358aaa26 | ||
|
|
a9b48320a3 | ||
|
|
95a3b87062 | ||
|
|
8cef31086b | ||
|
|
55790964b7 | ||
|
|
5ae574374f | ||
|
|
47f209bf0a | ||
|
|
d81a4a76de | ||
|
|
8841bcc93f | ||
|
|
77ba41f8da | ||
|
|
b80d47aff8 | ||
|
|
d67da5c9d0 | ||
|
|
d6c3386102 | ||
|
|
10c263a5b8 | ||
|
|
b070c9ee65 | ||
|
|
e98127a804 | ||
|
|
3e3a4612e6 | ||
|
|
a5283c565d | ||
|
|
b46588f247 | ||
|
|
e6ddb0485a | ||
|
|
334884e96a | ||
|
|
69a71287e6 | ||
|
|
7485afecaf | ||
|
|
0a52a99ca9 | ||
|
|
c46e87fd7a | ||
|
|
dd230b0b6e | ||
|
|
120d780267 | ||
|
|
0af925fe82 | ||
|
|
b404dacfde | ||
|
|
cbd1fa639d | ||
|
|
43d4be8eeb | ||
|
|
f4d1ee8027 | ||
|
|
7a30a31430 | ||
|
|
fdfcc15c10 | ||
|
|
697b05b118 | ||
|
|
0462a5d5a0 | ||
|
|
da6fa663e7 | ||
|
|
10f87428c3 | ||
|
|
13e652e43f | ||
|
|
2afb1c2c2e | ||
|
|
2fb744f865 | ||
|
|
de4b1db490 | ||
|
|
8536aaef7b | ||
|
|
94b093b617 | ||
|
|
6a01412af9 | ||
|
|
bc04b6457e | ||
|
|
5b5e932ec4 | ||
|
|
f1dfbcdd65 | ||
|
|
233af33dac | ||
|
|
993e5e021c | ||
|
|
02e40f506b | ||
|
|
e558264977 | ||
|
|
9a48c43aa1 | ||
|
|
5cf5a45438 | ||
|
|
110c4696ad | ||
|
|
ac6236b268 | ||
|
|
00f022ccf8 | ||
|
|
b13672596f | ||
|
|
8d45b92eba | ||
|
|
05c788ce9d | ||
|
|
9c286eeddf | ||
|
|
e1e7dc5bd8 | ||
|
|
ce80b7a212 | ||
|
|
e587d84590 | ||
|
|
d9fa9ba1eb | ||
|
|
abf1d1f1ca | ||
|
|
90fd0a5a15 | ||
|
|
64c288a683 | ||
|
|
abd39318e6 | ||
|
|
c0422c4e21 | ||
|
|
74884a20eb | ||
|
|
1cb0441bd8 | ||
|
|
3f2cde2db7 | ||
|
|
47e7935274 | ||
|
|
a2dcc91ddf | ||
|
|
c67c130caa | ||
|
|
90a95f562b | ||
|
|
02dc46dcd7 | ||
|
|
5b4878df3b | ||
|
|
3b714446b4 | ||
|
|
9ba90a674a | ||
|
|
d3ee9f23c2 |
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/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.",
|
"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.32.0",
|
"version": "1.34.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ npm run typecheck # tsc --noEmit, strict mode
|
|||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||||
|
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ jobs:
|
|||||||
- name: Frontend JS syntax check
|
- name: Frontend JS syntax check
|
||||||
run: npm run check:frontend-syntax
|
run: npm run check:frontend-syntax
|
||||||
|
|
||||||
|
# Asks `vitest list` what CI would actually collect, rather than matching
|
||||||
|
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
||||||
|
# (config/test-suites.ts) passes locally and dies in the test job with
|
||||||
|
# "browserType.launch: Executable doesn't exist".
|
||||||
|
- name: Browser-test exclusion check
|
||||||
|
run: npm run check:browser-excludes
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
|
|
||||||
|
|||||||
+160
@@ -1,5 +1,165 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.34.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6aecc3b: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||||
|
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||||
|
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||||
|
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||||
|
|
||||||
|
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||||
|
|
||||||
|
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||||
|
|
||||||
|
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||||
|
|
||||||
|
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||||
|
|
||||||
|
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||||
|
|
||||||
|
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||||
|
|
||||||
|
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||||
|
|
||||||
|
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||||
|
|
||||||
|
## 1.33.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f776ad8: ### Thanks
|
||||||
|
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
||||||
|
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
||||||
|
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
||||||
|
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
||||||
|
|
||||||
|
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
||||||
|
|
||||||
|
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
||||||
|
|
||||||
|
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
||||||
|
|
||||||
|
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
||||||
|
|
||||||
|
## 1.33.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- e439cf0: ### Thanks
|
||||||
|
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
|
||||||
|
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
|
||||||
|
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
|
||||||
|
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
|
||||||
|
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
|
||||||
|
|
||||||
|
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
|
||||||
|
|
||||||
|
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
|
||||||
|
|
||||||
|
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
|
||||||
|
|
||||||
|
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
|
||||||
|
|
||||||
|
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
|
||||||
|
|
||||||
|
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
|
||||||
|
|
||||||
|
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
|
||||||
|
|
||||||
|
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
|
||||||
|
|
||||||
|
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
|
||||||
|
|
||||||
|
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
|
||||||
|
|
||||||
|
## 1.33.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- ### Thanks
|
||||||
|
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
||||||
|
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
||||||
|
|
||||||
|
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||||
|
|
||||||
|
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||||
|
|
||||||
|
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
||||||
|
|
||||||
|
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||||
|
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||||
|
|
||||||
|
Thanks @opticon454.
|
||||||
|
|
||||||
|
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
||||||
|
|
||||||
|
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
||||||
|
|
||||||
|
Thanks @opticon454 (#487, #485).
|
||||||
|
|
||||||
|
## 1.33.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
|
||||||
|
|
||||||
|
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
|
||||||
|
|
||||||
|
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
|
||||||
|
|
||||||
|
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
|
||||||
|
|
||||||
|
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
|
||||||
|
|
||||||
|
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
|
||||||
|
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
|
||||||
|
- @julian3xl for the Session Manager row fix (#477), their first contribution.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||||
|
|
||||||
|
## 1.32.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 13e652e: Terminal copy: copying text out of a Claude Code or Codex pane no longer puts the pane's two-column transcript gutter on the clipboard, so pasted lines arrive flush instead of indented (#469). The width comes from the CLI registry (`capabilities.transcriptGutter`, 2 for claude and codex, measured on live panes) and is only a ceiling: a selection only ever shifts as a block, so its own indentation survives. Other CLIs and shells are untouched. It works in split panes and detached session windows too, and can be turned off per device in App Settings under Selection & clipboard.
|
||||||
|
- 13e652e: Sessions: recovering a Claude session whose tmux pane had died relaunched `claude --session-id <id>`, which Claude refuses once that id has a transcript, so the pane died again straight away and the conversation was stranded. The relaunch now resumes the conversation (`--resume <id> || --session-id <id>`), including when tmux lost the whole session (#467).
|
||||||
|
- 00f022c: Terminal: when a burst of output overflows the render queue and a frame has to be dropped, the repaint that repairs it is now retried until it actually happens, instead of being scheduled once and silently skipped when another load was in flight (#470).
|
||||||
|
- 13e652e: Mobile: a long press on blank terminal space on Android Chrome no longer opens the keyboard and blanks the terminal (#471, fixes #360). The long-press guards are now armed before the press is checked for selectable text, so a press on empty space is swallowed the same way a press on a word already was.
|
||||||
|
- 13e652e: Sessions: a tab whose agent has exited (the CLI quit, but tmux kept the pane) now says so with a muted dot and an `exited (137)` badge, instead of looking like an idle session (#466, part 1 of #446). The state is published as `paneExit` on the session and survives a restart. Nothing closes such sessions yet; that is part 2.
|
||||||
|
- 13e652e: Docker: optional GitHub CLI and Azure CLI for private repositories (#472). Both are off by default. With `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` as build args in `docker-compose.override.yml`, the server image gets `gh` and/or `az` (with the `azure-devops` extension) wired in as git credential helpers, so after one `gh auth login` or `az login` from a shell session, Add Case → Clone Repo can clone private GitHub and Azure DevOps repositories. `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` do the same for the Docker-case agent image, and only then are the sign-ins copied into new case containers. In multi-user mode a non-admin's clone runs with the credential helpers cleared. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update.
|
||||||
|
- 13e652e: Run menu: the Gemini, Antigravity and OMP run buttons now show their own colours on every skin; they rendered in Claude blue on all skins except OG (#463). The CLI registry's `accent` values were also corrected to the colours the UI really paints, and a test now guards the stylesheet trap that caused it.
|
||||||
|
- 13e652e: Terminal: five ways the browser terminal could silently stop being correct are fixed (#431, #464). The browser terminal and the PTY can no longer disagree about their width, which is what produced doubled lines and half-overwritten text ("text gets muffled sometimes"): there is now one function that sizes the terminal, and every resize is answered with the geometry the PTY really holds. A replay clear goes through the terminal's own queue, so bytes written just before it no longer fuse into the next snapshot. A renderer that stops painting after an iOS PWA is backgrounded heals itself instead of needing a reload. Every terminal capture has a deadline that also covers the response body, and a capture that runs out of time during a tab switch falls back to the bounded tail instead of leaving a blank pane. Output lost to a half-open WebSocket is repainted on the next successful open. The service worker's precache list is now generated by the build and its cache is rotated per build, so old releases' assets no longer pile up.
|
||||||
|
- 13e652e: Docker: new `docker/Update-Codeman.sh` for the major-update path the docs used to describe by hand (#465). It rebuilds the image with `--no-cache` before taking the stack down, clears the build-artefact volumes, refuses to run when another checkout's Compose project already owns the same name, and then hands over to `Start-Codeman.sh`.
|
||||||
|
- 13e652e: Approvals: a session that is idle only because it is waiting on its own background work (Claude Code's `1 monitor` footer chip, or a Codex background terminal) no longer raises the yellow NEEDS YOU alert or a push (#473, fixes #468). Its idle item is opened already acknowledged, and the tab, the home screens and the rail show a small `watching` badge next to the state instead. The item still exists in the Approvals Inbox, and the TUI's pending count now leaves acknowledged items out.
|
||||||
|
- b404dac: Maintainer fixes applied while landing this batch:
|
||||||
|
- Terminal (#431): while another device holds the pane's width, a resize retry no longer re-fits xterm to the container and re-wraps the whole buffer every 30 s, and no longer clears scrollback for a redraw that never comes. The PTY's spawn geometry is now recorded at attach, so `ptyGeometry` never reports a size the PTY never held.
|
||||||
|
- Terminal (#470): the `TERMINAL DROP` crash-trail line is logged once per recovery window instead of once per dropped frame (which wiped the rest of the trail within a second), and a refresh that died at its fetch deadline is no longer retried.
|
||||||
|
- Sessions (#467): the resume pin also covers the branch where tmux lost the whole session, the conversation id Codeman reports follows what the relaunch actually resumed, and the test setup strips `CLAUDE_CONFIG_DIR` so the suite stays green for anyone running a separate Claude config dir.
|
||||||
|
- Sessions (#466): detailed sidebar and rail rows show an `exited` pill instead of `idle`, the exit is announced to screen readers, and the user manual's tab-appearance table lists the new state.
|
||||||
|
- Approvals (#473): a failed pane capture clears the `watching` badge rather than keeping a stale one (a failure now falls toward an alert, not toward silence), and the header bell's count leaves acknowledged items out, matching the TUI.
|
||||||
|
- Run menu (#463): the Gemini and Antigravity run buttons no longer render two-tone on phones, Gemini's registry accent matches its tab badge, and a test now guards the stylesheet trap for every run mode.
|
||||||
|
- Docker (#465): `Update-Codeman.sh` removes exactly the two build-artefact volumes it names instead of every named volume in the project, reports a failing `docker compose` instead of exiting silently, and its docs and comments were corrected. (#472): the multi-user notes say that a non-admin's seeded Docker case also receives the gh/az sign-in when those switches are on.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
- @irisitymichaelgrundberg for four PRs in this release: the `watching` badge that stops background work from raising false alerts (#473, from their own report #468), the exited-agent badge (#466) and the dead-pane resume fix (#467), both from their report #446, and the transcript-gutter strip for copied text (#469), a follow-up to their #451.
|
||||||
|
- @rounakdatta for the terminal resilience work (#431) and the dropped-frame recovery (#470), both from their report #464, and for answering four rounds of review in full.
|
||||||
|
- @opticon454 for private-repository support in the Docker images (#472), the `Update-Codeman.sh` script (#465) and the run-button colour fix (#463).
|
||||||
|
- @DodgyBadger for the Android long-press fix (#471), from their own report #360.
|
||||||
|
|
||||||
## 1.32.0
|
## 1.32.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "claude",
|
"id": "claude",
|
||||||
"label": "Claude",
|
"label": "Claude Code",
|
||||||
"shortBadge": "CC",
|
"shortBadge": "CC",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"order": 0,
|
"order": 0,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
export const BROWSER_TEST_GLOBS = [
|
export const BROWSER_TEST_GLOBS = [
|
||||||
'test/tab-rail-resize.browser.test.ts',
|
'test/tab-rail-resize.browser.test.ts',
|
||||||
|
'test/tab-activation.browser.test.ts',
|
||||||
'test/session-sidebar-ux.browser.test.ts',
|
'test/session-sidebar-ux.browser.test.ts',
|
||||||
'test/session-options-responsive.browser.test.ts',
|
'test/session-options-responsive.browser.test.ts',
|
||||||
'test/inline-rename.test.ts',
|
'test/inline-rename.test.ts',
|
||||||
@@ -31,8 +32,12 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/capture-geometry-retry.browser.test.ts',
|
'test/capture-geometry-retry.browser.test.ts',
|
||||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||||
'test/split-pane-terminal.browser.test.ts',
|
'test/split-pane-terminal.browser.test.ts',
|
||||||
|
'test/shift-enter-keypress.browser.test.ts',
|
||||||
|
'test/key-tester.browser.test.ts',
|
||||||
|
'test/webhook-settings.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.browser.test.ts',
|
'test/split-pane-auto-collapse.browser.test.ts',
|
||||||
|
'test/mobile-ime-preview.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ TZ=Australia/Perth
|
|||||||
# this value rebuilds the image with a matching account.
|
# this value rebuilds the image with a matching account.
|
||||||
CODEMAN_RUNTIME_USER=codeman
|
CODEMAN_RUNTIME_USER=codeman
|
||||||
|
|
||||||
|
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
|
||||||
|
# are written to each image's system Git configuration when it is rebuilt, so
|
||||||
|
# deployments can configure a consistent default. Set both values together.
|
||||||
|
# GIT_USER_NAME=
|
||||||
|
# GIT_USER_EMAIL=
|
||||||
|
|
||||||
# Required. Persistent Codeman application data, CLI credentials, and session
|
# Required. Persistent Codeman application data, CLI credentials, and session
|
||||||
# state are stored here on the host and mounted at the runtime account's home
|
# state are stored here on the host and mounted at the runtime account's home
|
||||||
# directory in the container.
|
# directory in the container.
|
||||||
@@ -50,6 +56,14 @@ CODEMAN_USERNAME=admin
|
|||||||
# README.md, "Reverse-proxy host allowlist".
|
# README.md, "Reverse-proxy host allowlist".
|
||||||
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
|
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
|
||||||
|
|
||||||
|
# The GitHub CLI (gh) and the Azure CLI (az, with the azure-devops extension)
|
||||||
|
# can be built into the images as git credential helpers, so Codeman can clone
|
||||||
|
# private GitHub and Azure DevOps repositories. Both are OFF by default and are
|
||||||
|
# NOT set here: turn them on in docker-compose.override.yml with the build args
|
||||||
|
# CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ and, for the Docker-case agent image,
|
||||||
|
# the environment variables CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ. See
|
||||||
|
# README.md, "Private repositories".
|
||||||
|
|
||||||
# Optional: authenticate Gemini CLI without an interactive login.
|
# Optional: authenticate Gemini CLI without an interactive login.
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,115 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
|||||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||||
|
|
||||||
|
### Major updates
|
||||||
|
|
||||||
|
`Start-Codeman.sh` rebuilds the image on every start, but with the layer cache,
|
||||||
|
and it refreshes the build-artefact volumes selectively: `codeman-dist` when
|
||||||
|
the checkout's HEAD moved, `codeman-node-modules` only when `package-lock.json`
|
||||||
|
changed. That is right for an ordinary `git pull`. It is not enough when a
|
||||||
|
`server.Dockerfile` change bumps the Node base image without touching the
|
||||||
|
lockfile: `node-pty` is compiled from source (there is no Linux prebuild), so
|
||||||
|
the old `codeman-node-modules` volume would keep a build made for the previous
|
||||||
|
Node version. For that case, or whenever you want to be certain of what ships,
|
||||||
|
`docker/Update-Codeman.sh` force-rebuilds the image with no layer cache, stops
|
||||||
|
the stack, removes the `codeman-node-modules` and `codeman-dist` volumes, then
|
||||||
|
hands off to `Start-Codeman.sh` for the usual start:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash docker/Update-Codeman.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `--keep-volumes` to skip clearing them (safe only if you know the
|
||||||
|
rebuilt image's `node_modules`/`dist` did not change). The scripted default
|
||||||
|
is the "Resetting the build artefacts" procedure in
|
||||||
|
[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Only those
|
||||||
|
two volumes are removed, by name within this Compose project; any volume a
|
||||||
|
`docker-compose.override.yml` adds is left alone, and application data and
|
||||||
|
case workspaces are host bind mounts, never touched either way.
|
||||||
|
|
||||||
|
## Git commit identity
|
||||||
|
|
||||||
|
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GIT_USER_NAME='Your Name'
|
||||||
|
GIT_USER_EMAIL='you@example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose passes the values to the Codeman server build, and to the server process
|
||||||
|
when it builds Docker-case agent images. Both images write the pair to Git's
|
||||||
|
system configuration during their build, so commits retain the same identity
|
||||||
|
after a container or agent image is recreated. Set both values together; an
|
||||||
|
image build with only one value fails rather than using a partial identity. An
|
||||||
|
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
|
||||||
|
the server image's system-level default.
|
||||||
|
|
||||||
|
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
|
||||||
|
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
|
||||||
|
server container, then recreate any Docker cases that should use it.
|
||||||
|
|
||||||
|
## Private repositories (GitHub and Azure DevOps)
|
||||||
|
|
||||||
|
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||||
|
|
||||||
|
### Turning them on
|
||||||
|
|
||||||
|
Add the build arguments to `docker-compose.override.yml` (see [Local customisation](#local-customisation)), then rebuild with `Start-Codeman.sh`. Set only the one you need:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
codeman:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
CODEMAN_INSTALL_GH: '1'
|
||||||
|
CODEMAN_INSTALL_AZ: '1'
|
||||||
|
environment:
|
||||||
|
# The same two switches for the Docker-case agent image Codeman builds.
|
||||||
|
CODEMAN_AGENT_IMAGE_INSTALL_GH: '1'
|
||||||
|
CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `build: args:` pair controls the Codeman server image. The `environment:` pair controls the agent image for [Docker cases](../docs/docker-cases.md), which Codeman builds on the first Docker case; an agent image that already exists is not rebuilt by this, so run `node scripts/build-agent-image.mjs --no-cache` inside the container afterwards. The same variables work in front of that command when building it by hand. Values must be `0` or `1`; anything else stops the build with an error naming the argument.
|
||||||
|
|
||||||
|
They are not `.env` settings: turning a CLI on is a per-host choice, which is what the override file is for, and a new `.env.example` key makes the in-app updater refuse to update every existing installation until its `.env` gains the key.
|
||||||
|
|
||||||
|
The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing functional behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. With both off the image is functionally unchanged; it still carries the `AZURE_EXTENSION_DIR` variable, an empty extensions directory and one small layer that copies and then removes the helper script.
|
||||||
|
|
||||||
|
### Signing in
|
||||||
|
|
||||||
|
With a CLI on, the system Git configuration routes credentials through it:
|
||||||
|
|
||||||
|
| Host | Credential helper | Sign in with |
|
||||||
|
| ----------------------------------------------------- | ----------------------------------------- | ---------------------------- |
|
||||||
|
| `https://github.com`, `https://gist.github.com` | `gh auth git-credential` | `gh auth login` |
|
||||||
|
| `https://dev.azure.com`, `https://*.visualstudio.com` | `/usr/local/bin/git-credential-azure-cli` | `az login --use-device-code` |
|
||||||
|
|
||||||
|
Codeman itself still collects no Git credentials. Sign the container in once from a **Terminal / Shell** session (Run menu). The session runs as the runtime account, so the sign-in is stored under `CODEMAN_APPDATA_PATH` (`~/.config/gh`, `~/.azure`) and survives rebuilds and container recreation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh auth login # GitHub.com -> HTTPS -> "Login with a web browser" (device code)
|
||||||
|
az login --use-device-code # then: az devops configure --defaults organization=https://dev.azure.com/<org>
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, **Add Case → Clone Repo** accepts private `https://` URLs on those hosts, and `git clone` works from any session. Until a CLI is signed in its helper prints nothing, so a private clone fails immediately with the usual authentication error rather than waiting on a prompt.
|
||||||
|
|
||||||
|
**Multi-user mode:** every Codeman user's git runs as the same server account, so these sign-ins would otherwise be shared. Clone Repo therefore runs a **non-admin**'s clone and preflight with every git credential helper cleared (`git -c credential.helper=`): a non-admin can clone public repositories and anything their own SSH setup allows, but not a private https repository through the admin's `gh`/`az` sign-in. Admins, and single-user mode, keep the helpers. A non-admin's own agent sessions still run as that same account, and with the agent-image `gh`/`az` switches on, a non-admin's Docker case with credential seeding on also receives the server account's `gh`/`az` sign-in, the same as the Claude and Codex credentials; see `docs/security-architecture.md`, multi-user mode.
|
||||||
|
|
||||||
|
Azure DevOps is authenticated with an Entra ID access token that the helper requests from `az` for each Git operation, so nothing is written to disk beyond `az`'s own sign-in. An account that has to use a personal access token can set `AZURE_DEVOPS_EXT_PAT` for the container instead (for example under `environment:` in `docker-compose.override.yml`); the helper prefers it when present. SSH remotes are unaffected by any of this and keep using the account's own keys.
|
||||||
|
|
||||||
|
Docker cases copy these sign-ins into a case container only when the matching agent-image switch is on (`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` for `~/.config/gh/hosts.yml` and `config.yml`, `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` for the sign-in files from `~/.azure`) and the case has credential seeding on. With a switch off they are never copied, even when the files exist, because a GitHub token or an Azure refresh token is usable by anything in the container. The copies are made when the container is **created**, so an existing case container never picks them up: after turning a switch on, signing in, or rebuilding the agent image, **recreate the case container** (remove it; the next session in that case creates a fresh one).
|
||||||
|
|
||||||
|
The GitHub agent skill for `gh` installs into the runtime account's home in the same session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh skill install cli/cli gh --scope user
|
||||||
|
gh skill update gh # after a later gh release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versions
|
||||||
|
|
||||||
|
Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild.
|
||||||
|
|
||||||
## Local customisation
|
## Local customisation
|
||||||
|
|
||||||
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
|
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# The scripted major-update path for the Docker Compose deployment.
|
||||||
|
#
|
||||||
|
# docker/README.md and docs/docker-self-update.md both point operators here for
|
||||||
|
# anything the in-app updater itself refuses to apply: a changed
|
||||||
|
# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required
|
||||||
|
# `.env.example` key. None of those can be applied by a container restarting
|
||||||
|
# itself — a restart reuses the existing image and configuration (see "The
|
||||||
|
# environment gate" in docs/docker-self-update.md) — so this script does the
|
||||||
|
# three things an in-place update cannot: force a real image rebuild with no
|
||||||
|
# layer cache, stop the stack, then hand off to Start-Codeman.sh for the same
|
||||||
|
# careful PUID/PGID, override-file and fingerprint handling every other start
|
||||||
|
# goes through.
|
||||||
|
#
|
||||||
|
# ⚠️ Build BEFORE stopping the stack, deliberately, same reasoning as
|
||||||
|
# Start-Codeman.sh's own build-then-down ordering: the build needs nothing
|
||||||
|
# stopped, so a slow --no-cache rebuild costs no downtime, and a build failure
|
||||||
|
# (a bad Dockerfile edit, a network blip pulling a base image) leaves the
|
||||||
|
# ALREADY-RUNNING stack untouched instead of stopped with nothing to bring it
|
||||||
|
# back.
|
||||||
|
#
|
||||||
|
# ⚠️ Clears the codeman-node-modules/codeman-dist named volumes by DEFAULT.
|
||||||
|
# Docker seeds a named volume from the image only while that volume is EMPTY,
|
||||||
|
# so a rebuilt image's fresh node_modules/dist otherwise sit unused behind a
|
||||||
|
# volume's old content and the container comes back up looking unchanged —
|
||||||
|
# exactly wrong for a script whose whole point is "be certain of what ships".
|
||||||
|
# Start-Codeman.sh clears codeman-dist when the checkout's HEAD moved and
|
||||||
|
# codeman-node-modules only when `package-lock.json` changed. A released
|
||||||
|
# server.Dockerfile change arrives through `git pull`, so HEAD moves and dist
|
||||||
|
# is refreshed, but a Dockerfile change that bumps the Node base image leaves
|
||||||
|
# the lockfile untouched while every native module (node-pty is compiled from
|
||||||
|
# source, there is no Linux prebuild) has to be rebuilt against the new Node
|
||||||
|
# ABI. Start-Codeman.sh would keep the old codeman-node-modules volume, and it
|
||||||
|
# never builds with --no-cache. This script clears BOTH volumes, and ONLY
|
||||||
|
# those two (targeted `docker volume rm` by Compose label, never
|
||||||
|
# `down --volumes`, which would also take any volume an override file adds).
|
||||||
|
# Pass --keep-volumes to opt out and reuse whatever is already in them.
|
||||||
|
#
|
||||||
|
# Usage: docker/Update-Codeman.sh [--keep-volumes]
|
||||||
|
# --keep-volumes Do not clear codeman-node-modules/codeman-dist. Safe to
|
||||||
|
# combine with a source change Start-Codeman.sh's own
|
||||||
|
# detection would have cleared anyway; unsafe if the reason
|
||||||
|
# you are here is a change to server.Dockerfile alone.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
env_file="$script_dir/.env"
|
||||||
|
compose_file="$script_dir/docker-compose.yaml"
|
||||||
|
|
||||||
|
keep_volumes=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--keep-volumes)
|
||||||
|
keep_volumes=1
|
||||||
|
;;
|
||||||
|
--help | -h)
|
||||||
|
printf 'Usage: bash %s [--keep-volumes]\n' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
|
||||||
|
printf 'Usage: bash %s [--keep-volumes]\n' "$0" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$env_file" ]]; then
|
||||||
|
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
|
||||||
|
printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same override-file discovery as Start-Codeman.sh, and deliberately kept in
|
||||||
|
# step with it: a stack built here and started there must resolve to the exact
|
||||||
|
# same Compose files, or this script's build could target a configuration the
|
||||||
|
# handoff's own `up` never actually uses. Compose's own precedence (measured on
|
||||||
|
# v5.5.0 with both present: it uses .yml and ignores .yaml).
|
||||||
|
override_yml="$script_dir/docker-compose.override.yml"
|
||||||
|
override_yaml="$script_dir/docker-compose.override.yaml"
|
||||||
|
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
|
||||||
|
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
|
||||||
|
"$override_yml" "$override_yaml" >&2
|
||||||
|
fi
|
||||||
|
compose_files=(-f "$compose_file")
|
||||||
|
for override_file in "$override_yml" "$override_yaml"; do
|
||||||
|
if [[ -f "$override_file" ]]; then
|
||||||
|
compose_files+=(-f "$override_file")
|
||||||
|
printf 'Using Compose override file: %s\n' "$override_file"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
||||||
|
|
||||||
|
# Collision guard. Start-Codeman.sh has no equivalent; this is the only one,
|
||||||
|
# and it has to run before this script's own --no-cache build, `down` and
|
||||||
|
# volume removal below. docker-compose.yaml hard-codes `name: codeman`, so a
|
||||||
|
# second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME Compose
|
||||||
|
# project as any other checkout on the host and would operate on ITS
|
||||||
|
# containers and volumes.
|
||||||
|
#
|
||||||
|
# The project name is read from the resolved config's top-level `name` key
|
||||||
|
# (the first `name` in the output; nested ones come later), the same parse
|
||||||
|
# Start-Codeman.sh uses. `--format json` needs Compose v2.3+. This is the first
|
||||||
|
# `docker` call the script makes, so its failure is reported here rather than
|
||||||
|
# left to `set -e`, which would exit with no output at all.
|
||||||
|
if ! project_config=$("${compose_command[@]}" config --format json); then
|
||||||
|
printf 'Error: `docker compose config --format json` failed (see the message above, if any).\n' >&2
|
||||||
|
printf 'Check that Docker and Compose v2.3+ are installed and on PATH, and that\n' >&2
|
||||||
|
printf '%s and the Compose files in %s are valid.\n' "$env_file" "$script_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
project_name=$(
|
||||||
|
printf '%s\n' "$project_config" |
|
||||||
|
sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1
|
||||||
|
)
|
||||||
|
if [[ -n "$project_name" ]]; then
|
||||||
|
# `|| true` on the pipeline's LAST command: under `set -o pipefail`, `grep -v`
|
||||||
|
# exits 1 when nothing survives the filter — the ordinary, no-collision case,
|
||||||
|
# since `docker ps` finds nothing at all on a first-ever deployment or a
|
||||||
|
# single matching (own) working_dir gets filtered out. Without it, that exit
|
||||||
|
# status propagates through the command substitution and `set -e` aborts the
|
||||||
|
# WHOLE script right here, every time, regardless of whether a collision
|
||||||
|
# actually exists — caught only by actually running this end-to-end (a
|
||||||
|
# static text/regex check on the source cannot see it). The empty-line
|
||||||
|
# filter keeps a container with no working_dir label from winning head -n1
|
||||||
|
# and hiding a real collision behind it.
|
||||||
|
other_working_dir=$(
|
||||||
|
docker ps -a --filter "label=com.docker.compose.project=$project_name" \
|
||||||
|
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null |
|
||||||
|
grep -v -F -x -- "$script_dir" | grep -v '^$' | head -n1 || true
|
||||||
|
)
|
||||||
|
if [[ -n "$other_working_dir" ]]; then
|
||||||
|
printf 'Error: Compose project "%s" is already in use by a DIFFERENT checkout:\n' "$project_name" >&2
|
||||||
|
printf ' %s\n' "$other_working_dir" >&2
|
||||||
|
printf 'This checkout is:\n' >&2
|
||||||
|
printf ' %s\n' "$script_dir" >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'docker-compose.yaml hard-codes `name: %s`, so two checkouts on the same host\n' "$project_name" >&2
|
||||||
|
printf 'collide unless each one sets a distinct COMPOSE_PROJECT_NAME. Continuing would\n' >&2
|
||||||
|
printf 'rebuild and stop the OTHER checkout'"'"'s running container and, by default,\n' >&2
|
||||||
|
printf 'delete its codeman-node-modules/codeman-dist volumes.\n' >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'Fix: export COMPOSE_PROJECT_NAME=<something-unique-to-this-checkout> before\n' >&2
|
||||||
|
printf 'running this script, then retry.\n' >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'If instead THIS checkout was moved or renamed after its container was created,\n' >&2
|
||||||
|
printf 'the path above is its own old location: remove the old container (for example\n' >&2
|
||||||
|
printf '`docker rm -f <container>` for the codeman container) and retry, rather than\n' >&2
|
||||||
|
printf 'setting COMPOSE_PROJECT_NAME, which would start a second project beside it.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same owner-detection Start-Codeman.sh uses to derive PUID/PGID for its own
|
||||||
|
# build — without it, the --no-cache build below gets Compose's untouched
|
||||||
|
# default of 1000:1000, and on any host whose appdata owner differs (99:100 on
|
||||||
|
# Unraid, per docker/README.md's chown example), Start-Codeman.sh's own
|
||||||
|
# correctly-PUID'd build during the handoff then rebuilds those layers with the
|
||||||
|
# right values anyway — so the "no cache, certain of what ships" image this
|
||||||
|
# script produces is not the one that actually ends up running.
|
||||||
|
#
|
||||||
|
# Deliberately NOT the same as Start-Codeman.sh's own handling of a MISSING
|
||||||
|
# appdata directory (which creates it): this script updates an EXISTING
|
||||||
|
# deployment, so a missing appdata path means there is nothing here yet to
|
||||||
|
# update, and creating one would just be this script quietly doing
|
||||||
|
# Start-Codeman.sh's first-run job worse.
|
||||||
|
appdata_path=$(
|
||||||
|
"${compose_command[@]}" config --environment |
|
||||||
|
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||||
|
)
|
||||||
|
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
|
||||||
|
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
|
||||||
|
printf 'Run docker/Start-Codeman.sh first to set up a new deployment.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
|
||||||
|
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
|
||||||
|
owner_of() {
|
||||||
|
stat -c '%u:%g' -- "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! owner_ids=$(owner_of "$appdata_path"); then
|
||||||
|
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PUID=${owner_ids%%:*}
|
||||||
|
export PGID=${owner_ids##*:}
|
||||||
|
|
||||||
|
if [[ "$PUID" == '0' ]]; then
|
||||||
|
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
|
||||||
|
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --no-cache, always: a plain `build` reuses cached layers (npm install, apt
|
||||||
|
# packages, the CLI installs baked into the image) and can silently keep them
|
||||||
|
# frozen at whatever they were the day the cache was populated — exactly wrong
|
||||||
|
# for a major update, whose whole point is being certain of what actually
|
||||||
|
# ships. `scripts/build-agent-image.mjs` makes the same call for the same
|
||||||
|
# reason (see its entry in CLAUDE.md's Additional Commands table). Runs BEFORE
|
||||||
|
# the stack is stopped — see the header comment for why.
|
||||||
|
printf 'Building a fresh image (--no-cache)...\n'
|
||||||
|
"${compose_command[@]}" build --no-cache
|
||||||
|
|
||||||
|
printf 'Stopping the stack...\n'
|
||||||
|
if [[ "$keep_volumes" == '1' || -n "$project_name" ]]; then
|
||||||
|
"${compose_command[@]}" down
|
||||||
|
else
|
||||||
|
# No resolvable project name means the label filter below could match
|
||||||
|
# nothing, so fall back to Compose's own removal, and say what it really does.
|
||||||
|
printf 'Warning: could not resolve the Compose project name; clearing EVERY named volume\n' >&2
|
||||||
|
printf 'in this Compose project (override file included) with `down --volumes` instead.\n' >&2
|
||||||
|
"${compose_command[@]}" down --volumes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Targeted removal of exactly the two build-artefact volumes, scoped by label to
|
||||||
|
# THIS project (the volume key alone is shared by any other stack declaring the
|
||||||
|
# same key). Same lookup as Start-Codeman.sh's refresh. A failure is reported,
|
||||||
|
# not fatal: the stack is already down, and the handoff below is what brings
|
||||||
|
# it back up.
|
||||||
|
if [[ "$keep_volumes" != '1' && -n "$project_name" ]]; then
|
||||||
|
printf 'Clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n'
|
||||||
|
for key in codeman-node-modules codeman-dist; do
|
||||||
|
volume_name=$(
|
||||||
|
docker volume ls -q \
|
||||||
|
--filter "label=com.docker.compose.volume=$key" \
|
||||||
|
--filter "label=com.docker.compose.project=$project_name" |
|
||||||
|
head -n1
|
||||||
|
) || volume_name=''
|
||||||
|
if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then
|
||||||
|
printf 'Warning: could not remove volume %s; the container may keep serving the\n' "$volume_name" >&2
|
||||||
|
printf 'previous build from it. Remove it by hand and rerun this script.\n' >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start-Codeman.sh does everything a plain `up -d` does not: re-derives
|
||||||
|
# PUID/PGID, pre-creates CODEMAN_CASES_PATH with the right ownership, resolves
|
||||||
|
# DOCKER_SOCKET_GID, records the server.Dockerfile/docker-compose.yaml
|
||||||
|
# fingerprint the in-app updater's gate reads on every future update, and
|
||||||
|
# starts the (already freshly built) image. Reimplementing any of that here
|
||||||
|
# would only risk drifting out of step with it — hand off instead, exactly as
|
||||||
|
# docs/docker-self-update.md's own reset procedure does.
|
||||||
|
#
|
||||||
|
# ⚠️ `bash`, not a bare exec of the path: Start-Codeman.sh is committed
|
||||||
|
# non-executable (100644), the same as this script, and is documented
|
||||||
|
# everywhere as `bash docker/Start-Codeman.sh` rather than
|
||||||
|
# `./docker/Start-Codeman.sh` — execing the bare path fails with EACCES.
|
||||||
|
printf 'Handing off to Start-Codeman.sh...\n'
|
||||||
|
exec bash "$script_dir/Start-Codeman.sh"
|
||||||
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
|
libsecret-1-0 \
|
||||||
tmux \
|
tmux \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
curl \
|
curl \
|
||||||
@@ -26,6 +27,88 @@ RUN apt-get update \
|
|||||||
openssh-client \
|
openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system
|
||||||
|
# git credential helpers as docker/server.Dockerfile, so an agent in a Docker
|
||||||
|
# case can clone and push to private GitHub / Azure DevOps repositories. The
|
||||||
|
# sign-ins themselves are NOT baked in: `~/.config/gh` and `~/.azure` are seeded
|
||||||
|
# per container at launch like every other CLI's credentials (CRED_STORES in
|
||||||
|
# src/docker-hosts.ts), and a helper whose CLI is not signed in prints nothing,
|
||||||
|
# so git fails fast instead of prompting. See server.Dockerfile for why the
|
||||||
|
# vendor apt repositories are configured here rather than via deb_install.sh.
|
||||||
|
#
|
||||||
|
# Each is OPT-IN and OFF by default, like the server image: CODEMAN_INSTALL_GH=1
|
||||||
|
# / CODEMAN_INSTALL_AZ=1 turn one on; off leaves no repository, package,
|
||||||
|
# extension or helper entry. scripts/build-agent-image.mjs and the in-app
|
||||||
|
# auto-build pass them from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ in their own
|
||||||
|
# environment (for the Compose deployment: `environment:` in
|
||||||
|
# docker-compose.override.yml), and pass nothing when those are unset, so
|
||||||
|
# these defaults (off) apply.
|
||||||
|
ARG CODEMAN_INSTALL_GH=0
|
||||||
|
ARG CODEMAN_INSTALL_AZ=0
|
||||||
|
RUN set -eux; \
|
||||||
|
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
|
||||||
|
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
|
||||||
|
done; \
|
||||||
|
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
pkgs=""; \
|
||||||
|
install -d -m 0755 /etc/apt/keyrings; \
|
||||||
|
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||||
|
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
|
||||||
|
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
||||||
|
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
|
> /etc/apt/sources.list.d/github-cli.list; \
|
||||||
|
pkgs="${pkgs} gh"; \
|
||||||
|
fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
|
||||||
|
https://packages.microsoft.com/keys/microsoft.asc; \
|
||||||
|
chmod go+r /etc/apt/keyrings/microsoft.asc; \
|
||||||
|
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
|
||||||
|
> /etc/apt/sources.list.d/azure-cli.list; \
|
||||||
|
pkgs="${pkgs} azure-cli"; \
|
||||||
|
fi; \
|
||||||
|
if [ -n "${pkgs}" ]; then \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ${pkgs}; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then gh --version; fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then az version --output none; fi
|
||||||
|
|
||||||
|
# Outside HOME so the seeded `~/.azure` (auth files only) never has to carry
|
||||||
|
# extensions. gid 0 + group-writable, the same arbitrary-uid convention as HOME
|
||||||
|
# below, so `az extension update` works as whatever uid the container runs as.
|
||||||
|
# Created even without az; an empty directory costs nothing.
|
||||||
|
ENV AZURE_EXTENSION_DIR=/opt/az-extensions
|
||||||
|
RUN set -eux; \
|
||||||
|
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
az extension add --name azure-devops --only-show-errors; \
|
||||||
|
rm -rf /root/.azure; \
|
||||||
|
fi; \
|
||||||
|
chgrp -R 0 "${AZURE_EXTENSION_DIR}"; \
|
||||||
|
chmod -R g=u "${AZURE_EXTENSION_DIR}"
|
||||||
|
|
||||||
|
# Only an installed CLI gets a helper entry (see server.Dockerfile).
|
||||||
|
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
|
||||||
|
RUN set -eux; \
|
||||||
|
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||||
|
for host in https://github.com https://gist.github.com; do \
|
||||||
|
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
|
||||||
|
done; \
|
||||||
|
fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
|
||||||
|
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
git config --system "credential.${host}.useHttpPath" true; \
|
||||||
|
done; \
|
||||||
|
else \
|
||||||
|
rm -f /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
fi
|
||||||
|
|
||||||
# The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from
|
# The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from
|
||||||
# config/clis.stock.json so a new stock CLI needs no edit here. The default is
|
# config/clis.stock.json so a new stock CLI needs no edit here. The default is
|
||||||
# today's literal list, so a bare `docker build` still produces the same image.
|
# today's literal list, so a bare `docker build` still produces the same image.
|
||||||
@@ -44,6 +127,10 @@ RUN apt-get update \
|
|||||||
# A different order is a different RUN string, which is a different layer hash and
|
# A different order is a different RUN string, which is a different layer hash and
|
||||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||||
|
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||||
|
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||||
|
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
@@ -167,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
|||||||
&& chgrp -R 0 /home/agent \
|
&& chgrp -R 0 /home/agent \
|
||||||
&& chmod -R g=u /home/agent
|
&& chmod -R g=u /home/agent
|
||||||
|
|
||||||
|
# Docker cases have a fresh, container-owned home directory. Declare the
|
||||||
|
# optional identity here so changing it invalidates only this final layer, then
|
||||||
|
# configure Git's system defaults. A user-level config still takes precedence.
|
||||||
|
ARG GIT_USER_EMAIL=
|
||||||
|
ARG GIT_USER_NAME=
|
||||||
|
RUN set -eux; \
|
||||||
|
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||||
|
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||||
|
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
git config --system user.name "${GIT_USER_NAME}"; \
|
||||||
|
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||||
|
fi
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
WORKDIR /home/agent
|
WORKDIR /home/agent
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ services:
|
|||||||
dockerfile: docker/server.Dockerfile
|
dockerfile: docker/server.Dockerfile
|
||||||
args:
|
args:
|
||||||
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
||||||
|
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||||
|
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||||
PGID: ${PGID:-1000}
|
PGID: ${PGID:-1000}
|
||||||
PUID: ${PUID:-1000}
|
PUID: ${PUID:-1000}
|
||||||
image: ${CODEMAN_IMAGE}
|
image: ${CODEMAN_IMAGE}
|
||||||
@@ -32,6 +34,10 @@ services:
|
|||||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||||
|
# Passed through only so Codeman can use the same identity when it builds
|
||||||
|
# the Docker-case agent image.
|
||||||
|
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||||
|
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||||
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||||
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||||
# defaults to empty rather than requiring a line in every .env.
|
# defaults to empty rather than requiring a line in every .env.
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Git credential helper for Azure DevOps, backed by the signed-in Azure CLI.
|
||||||
|
#
|
||||||
|
# Configured in the image's system gitconfig for https://dev.azure.com and
|
||||||
|
# https://*.visualstudio.com (see server.Dockerfile). On `get` it answers with
|
||||||
|
# an Entra ID access token for the Azure DevOps resource as the password, the
|
||||||
|
# same token type Git Credential Manager uses for Azure Repos. It never prompts:
|
||||||
|
# when `az` is not signed in it prints nothing, so git fails fast with its own
|
||||||
|
# authentication error instead of hanging a request that has no terminal.
|
||||||
|
#
|
||||||
|
# AZURE_DEVOPS_EXT_PAT, the azure-devops extension's own PAT variable, is used
|
||||||
|
# instead when it is set, for accounts that authenticate with a PAT.
|
||||||
|
|
||||||
|
# `store` and `erase` are no-ops: the token belongs to az, which refreshes it.
|
||||||
|
[ "$1" = "get" ] || exit 0
|
||||||
|
|
||||||
|
# Drain the request git writes on stdin; the host scoping is in gitconfig.
|
||||||
|
cat >/dev/null
|
||||||
|
|
||||||
|
if [ -n "${AZURE_DEVOPS_EXT_PAT:-}" ]; then
|
||||||
|
printf 'username=pat\npassword=%s\n' "$AZURE_DEVOPS_EXT_PAT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v az >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
# 499b84ac-1321-427f-aa17-267ca6975798 is the fixed application ID of Azure
|
||||||
|
# DevOps: https://learn.microsoft.com/azure/devops/integrate/get-started/authentication/service-principal-managed-identity
|
||||||
|
token="$(az account get-access-token \
|
||||||
|
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
|
||||||
|
--query accessToken --output tsv 2>/dev/null)" || exit 0
|
||||||
|
[ -n "$token" ] || exit 0
|
||||||
|
|
||||||
|
printf 'username=azure-cli\npassword=%s\n' "$token"
|
||||||
+137
-1
@@ -39,6 +39,7 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
|
libsecret-1-0 \
|
||||||
make \
|
make \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
procps \
|
procps \
|
||||||
@@ -68,6 +69,111 @@ COPY --from=docker:29-cli \
|
|||||||
/usr/local/libexec/docker/cli-plugins/docker-buildx \
|
/usr/local/libexec/docker/cli-plugins/docker-buildx \
|
||||||
/usr/local/libexec/docker/cli-plugins/docker-buildx
|
/usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||||
|
|
||||||
|
# GitHub CLI and Azure CLI (with the azure-devops extension), so a user can sign
|
||||||
|
# this container in to GitHub and Azure DevOps from a Codeman shell session and
|
||||||
|
# then clone PRIVATE repositories, both from that session and through Add Case
|
||||||
|
# -> Clone Repo. Codeman still collects no Git credentials itself: the clone
|
||||||
|
# path (src/git-clone.ts) only inherits HOME and git's config, so whatever the
|
||||||
|
# user signs in to here is what authenticates, and nothing when they have not
|
||||||
|
# (the clone then fails fast with AUTH_REQUIRED, exactly as before).
|
||||||
|
#
|
||||||
|
# Each is OPT-IN and OFF by default: the image is functionally unchanged
|
||||||
|
# unless the build gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which
|
||||||
|
# a deployment sets under `build: args:` in docker-compose.override.yml
|
||||||
|
# (docker/README.md, "Private repositories"). Off installs no apt repository,
|
||||||
|
# package, extension or credential-helper entry; all that remains is the
|
||||||
|
# AZURE_EXTENSION_DIR variable, its empty directory and one layer that copies
|
||||||
|
# and then removes the helper script. The Azure CLI is the heavy one (~600 MB,
|
||||||
|
# mostly its bundled Python). The base docker-compose.yaml
|
||||||
|
# and .env deliberately do not carry them: turning a CLI on is a per-host
|
||||||
|
# choice, which is what the override file is for, and a new .env.example key
|
||||||
|
# would make the self-updater refuse existing installs until their .env gained
|
||||||
|
# it (docs/docker-self-update.md).
|
||||||
|
#
|
||||||
|
# Both come from their vendors' own apt repositories, the same ones the
|
||||||
|
# documented one-liners configure (https://github.com/cli/cli/blob/trunk/docs/install_linux.md
|
||||||
|
# and https://learn.microsoft.com/cli/azure/install-azure-cli-linux?pivots=apt).
|
||||||
|
# Microsoft's `deb_install.sh` is deliberately not piped into the build: it does
|
||||||
|
# exactly this plus a `gnupg` install, and a remote script run at build time is
|
||||||
|
# the one step a reviewer cannot read in this file. apt reads an ASCII-armoured
|
||||||
|
# `.asc` key directly, which is what keeps `gnupg` out of the image.
|
||||||
|
#
|
||||||
|
# Not pinned, unlike the agent CLIs below: nothing in Codeman depends on a
|
||||||
|
# particular gh or az behaviour, so the pinning argument there does not apply.
|
||||||
|
# The layer cache still keeps whatever version the first build fetched until a
|
||||||
|
# --no-cache rebuild.
|
||||||
|
ARG CODEMAN_INSTALL_GH=0
|
||||||
|
ARG CODEMAN_INSTALL_AZ=0
|
||||||
|
RUN set -eux; \
|
||||||
|
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
|
||||||
|
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
|
||||||
|
done; \
|
||||||
|
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
pkgs=""; \
|
||||||
|
install -d -m 0755 /etc/apt/keyrings; \
|
||||||
|
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||||
|
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
|
||||||
|
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
||||||
|
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
|
> /etc/apt/sources.list.d/github-cli.list; \
|
||||||
|
pkgs="${pkgs} gh"; \
|
||||||
|
fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
|
||||||
|
https://packages.microsoft.com/keys/microsoft.asc; \
|
||||||
|
chmod go+r /etc/apt/keyrings/microsoft.asc; \
|
||||||
|
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
|
||||||
|
> /etc/apt/sources.list.d/azure-cli.list; \
|
||||||
|
pkgs="${pkgs} azure-cli"; \
|
||||||
|
fi; \
|
||||||
|
if [ -n "${pkgs}" ]; then \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ${pkgs}; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The azure-devops extension goes into a SYSTEM directory rather than the
|
||||||
|
# default ~/.azure/cliextensions: HOME is the application-data bind mount, which
|
||||||
|
# hides anything installed there at build time. The directory is handed to the
|
||||||
|
# runtime account below (next to /opt/codeman-cli) so `az extension update`
|
||||||
|
# works from a session. Nothing that runs as root executes from it. It is
|
||||||
|
# created even without az, so the chown below does not have to know.
|
||||||
|
ENV AZURE_EXTENSION_DIR=/opt/codeman-az-extensions
|
||||||
|
RUN set -eux; \
|
||||||
|
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
az extension add --name azure-devops --only-show-errors; \
|
||||||
|
rm -rf /root/.azure; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git credential helpers, in the SYSTEM gitconfig so they apply to every
|
||||||
|
# account and survive a fresh application-data directory. Each one answers only
|
||||||
|
# for its own host and prints nothing when its CLI is not signed in, so git
|
||||||
|
# falls through to its normal non-interactive failure. Only an installed CLI
|
||||||
|
# gets an entry: a helper naming a missing binary would print an error on every
|
||||||
|
# clone from that host.
|
||||||
|
# github.com `gh auth git-credential`, what `gh auth setup-git` configures.
|
||||||
|
# Azure DevOps an Entra ID token from `az login` (git-credential-azure-cli),
|
||||||
|
# for both dev.azure.com and the legacy *.visualstudio.com hosts.
|
||||||
|
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
|
||||||
|
RUN set -eux; \
|
||||||
|
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||||
|
for host in https://github.com https://gist.github.com; do \
|
||||||
|
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
|
||||||
|
done; \
|
||||||
|
fi; \
|
||||||
|
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||||
|
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
|
||||||
|
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
git config --system "credential.${host}.useHttpPath" true; \
|
||||||
|
done; \
|
||||||
|
else \
|
||||||
|
rm -f /usr/local/bin/git-credential-azure-cli; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||||
# through Codeman sessions, and the configured host bind mount retains state.
|
# through Codeman sessions, and the configured host bind mount retains state.
|
||||||
#
|
#
|
||||||
@@ -107,13 +213,28 @@ COPY --from=docker:29-cli \
|
|||||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||||
# system directories for the root part of the start.
|
# system directories for the root part of the start.
|
||||||
|
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||||
|
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||||
|
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||||
|
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
|
||||||
|
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
|
||||||
|
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
|
||||||
|
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
||||||
|
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
||||||
|
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
||||||
|
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
||||||
|
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
||||||
|
# already carries it for the same reason (#352). It lives in the same
|
||||||
|
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
||||||
RUN npm install --global \
|
RUN npm install --global \
|
||||||
@anthropic-ai/claude-code@2.1.258 \
|
@anthropic-ai/claude-code@2.1.258 \
|
||||||
@google/gemini-cli@0.58.0 \
|
@google/gemini-cli@0.58.0 \
|
||||||
@openai/codex@0.152.1 \
|
@openai/codex@0.152.1 \
|
||||||
opencode-ai@1.18.26 \
|
opencode-ai@1.18.26 \
|
||||||
|
pnpm@12.6.0 \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||||
@@ -153,7 +274,7 @@ RUN set -eux; \
|
|||||||
--shell /bin/bash \
|
--shell /bin/bash \
|
||||||
"${CODEMAN_RUNTIME_USER}"; \
|
"${CODEMAN_RUNTIME_USER}"; \
|
||||||
fi; \
|
fi; \
|
||||||
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
chown -R "${PUID}:${PGID}" /opt/codeman-cli /opt/codeman-az-extensions
|
||||||
|
|
||||||
WORKDIR /opt/codeman
|
WORKDIR /opt/codeman
|
||||||
|
|
||||||
@@ -181,6 +302,21 @@ EXPOSE 3000
|
|||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Declare the optional identity immediately before configuring it so a change
|
||||||
|
# invalidates only this final layer. This is declarative setup: a persisted
|
||||||
|
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
|
||||||
|
ARG GIT_USER_EMAIL=
|
||||||
|
ARG GIT_USER_NAME=
|
||||||
|
RUN set -eux; \
|
||||||
|
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||||
|
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||||
|
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
git config --system user.name "${GIT_USER_NAME}"; \
|
||||||
|
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||||
|
fi
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["node", "dist/index.js", "web"]
|
CMD ["node", "dist/index.js", "web"]
|
||||||
|
|||||||
@@ -757,3 +757,13 @@ works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
|
|||||||
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
|
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
|
||||||
empty Codeman name, so the peer name stays derived: agents should name their
|
empty Codeman name, so the peer name stays derived: agents should name their
|
||||||
workers. Tests: `test/name-flag-injection.test.ts`.
|
workers. Tests: `test/name-flag-injection.test.ts`.
|
||||||
|
|
||||||
|
Later narrowing: `--name` is not only the peer name but also the `/resume` picker
|
||||||
|
entry and the terminal title, and a pinned title stops Claude generating its own, so
|
||||||
|
pinning the `w1-myapp` placeholder listed every conversation of a case under the same
|
||||||
|
name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`,
|
||||||
|
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
|
||||||
|
names leave Claude to title the conversation. A rename in Codeman appends a
|
||||||
|
`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the
|
||||||
|
row `/rename` writes. Tests: `test/claude-resume-title.test.ts`,
|
||||||
|
`test/routes/session-name-routes.test.ts`.
|
||||||
|
|||||||
@@ -311,6 +311,15 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
|
|||||||
turn that never started. Verified live; this is the most common silent failure on
|
turn that never started. Verified live; this is the most common silent failure on
|
||||||
this endpoint.
|
this endpoint.
|
||||||
|
|
||||||
|
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
|
||||||
|
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
|
||||||
|
a separate key, and the server re-presses Enter while the prompt is still visibly
|
||||||
|
sitting on the composer. Written straight into the pane in one piece, a prompt of
|
||||||
|
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
|
||||||
|
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
|
||||||
|
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
|
||||||
|
the raw write, and an explicit `"useMux": false` forces it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
@@ -691,6 +700,54 @@ normal `caseName`/`mode`/etc. body)
|
|||||||
jarring than a full relaunch, and folding it into the one-shot path is
|
jarring than a full relaunch, and folding it into the one-shot path is
|
||||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||||
|
|
||||||
|
## CLI management
|
||||||
|
|
||||||
|
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||||
|
|
||||||
|
| Method | Path | Body | Notes |
|
||||||
|
| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. |
|
||||||
|
| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. |
|
||||||
|
| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. |
|
||||||
|
| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. |
|
||||||
|
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||||
|
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||||
|
|
||||||
|
## MCP server sync
|
||||||
|
|
||||||
|
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||||
|
|
||||||
|
| Method | Path | Body | Notes |
|
||||||
|
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
|
||||||
|
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
|
||||||
|
|
||||||
|
Result (`data`):
|
||||||
|
|
||||||
|
- `applied` — `false` for the dry run.
|
||||||
|
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||||
|
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
||||||
|
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
||||||
|
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
|
||||||
|
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
|
||||||
|
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
|
||||||
|
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
|
||||||
|
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
|
||||||
|
|
||||||
|
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||||
|
|
||||||
|
## Webhook notifications
|
||||||
|
|
||||||
|
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||||
|
|
||||||
|
| Method | Path | Body | Notes |
|
||||||
|
| ------ | -------------------- | -------------------------------------------- | ----- |
|
||||||
|
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
|
||||||
|
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
|
||||||
|
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
|
||||||
|
|
||||||
|
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||||
|
|
||||||
## Voice dictation
|
## Voice dictation
|
||||||
|
|
||||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||||
|
|||||||
+615
-16
File diff suppressed because one or more lines are too long
@@ -0,0 +1,314 @@
|
|||||||
|
# CLI management Settings UI + write API — plan
|
||||||
|
|
||||||
|
> Tracked separately from `DEPLOYMENT_PLAN.md` (PR B2, merged) and `docs/copilot-integration-plan.md`
|
||||||
|
> (parked). This is "PR C" from the original #343 review: *"settings UI + write endpoints +
|
||||||
|
> auto-install, once we've settled the trust model... I want to make that call on its own, not
|
||||||
|
> inside a 100-file diff."*
|
||||||
|
>
|
||||||
|
> **Phase 0 is CLOSED as of 2026-09-21** — all three original pieces are IN SCOPE (expanded from
|
||||||
|
> this plan's first draft, which recommended #2/#3 as separate/out-of-scope; the user chose full
|
||||||
|
> scope instead, with the risk called out explicitly for #3 before confirming). See "Decisions"
|
||||||
|
> below for the full record.
|
||||||
|
|
||||||
|
## Status as of 2026-09-22
|
||||||
|
|
||||||
|
**Phases 1–6 are ALL IMPLEMENTED** (commits `da07b38c` "add cliManagementEnabled flag and GET
|
||||||
|
/api/clis" and `db4557d9` "Phases 3-6 - write API + custom entries + Settings UI", both on this
|
||||||
|
branch, `feat/cli-management`). Confirmed present in the tree: `cliManagementEnabled` in
|
||||||
|
`SettingsUpdateSchema`; `GET /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`,
|
||||||
|
`POST /api/clis`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id` in
|
||||||
|
`src/web/routes/cli-registry-routes.ts`; the `shell`/`claude` `UNDISABLEABLE_IDS` backend guard;
|
||||||
|
`isAdmin(req)` gating on both the list and write routes; `appendAdminAudit` wired into the install
|
||||||
|
route; tmp+rename+`0o600` writes in `registry-writer.ts`; the full Settings UI (row list, toggle,
|
||||||
|
Install button, custom-entry create/edit/delete form) in `settings-ui.js` + `index.html`.
|
||||||
|
`test/routes/cli-registry-routes.test.ts` (425 lines) and `test/cli-registry-no-id-branching.test.ts`
|
||||||
|
cover it. This status section, plus the fix and gap below, is the one piece of that work done in
|
||||||
|
a *different* session from the one that wrote Phases 1–6 — reviewed by reading the diff and
|
||||||
|
verifying each claim against the actual routes/tests, not by re-implementing anything.
|
||||||
|
|
||||||
|
### Gotcha found and fixed (commit `0c77dd0a`)
|
||||||
|
|
||||||
|
**Toggling a CLI off in Settings had no effect anywhere except the Settings row itself.**
|
||||||
|
`window.__codemanCliAvailable` — the flag `isCliAvailable()` reads client-side to gate the
|
||||||
|
welcome-screen buttons, the Run-menu dropdown and the mobile overview — is injected **once**, at
|
||||||
|
initial page render (`server.ts`), built purely from each CLI's own installed-on-PATH resolver
|
||||||
|
(`isClaudeAvailable()` etc.), with **no reference to the registry's `enabled` flag at all**. So
|
||||||
|
disabling a CLI here updated its own row and nothing else — every launch surface kept offering it,
|
||||||
|
both live and after a full page reload, since even a *fresh* render never consulted the registry.
|
||||||
|
Root-caused and reported by the user testing the live feature ("toggle those off, they still
|
||||||
|
appear in that menu and on the front main screen").
|
||||||
|
|
||||||
|
Fixed two places:
|
||||||
|
- `server.ts`: after building `available`, intersect the nine real `SessionMode` ids against
|
||||||
|
`enabledClis()`. `git`/`cloudflared` (utility binaries, not CLI registry entries) and
|
||||||
|
`deepseekBinary` (a secondary installed-only flag for the "add a profile" affordance) are
|
||||||
|
deliberately left alone — they were never registry-gated to begin with.
|
||||||
|
- `settings-ui.js`: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and
|
||||||
|
refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the
|
||||||
|
existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit
|
||||||
|
patch" reason — the server-side fix alone still left every surface stale until the next reload.
|
||||||
|
|
||||||
|
New test in `test/render-index-html.test.ts`: an installed-but-disabled CLI (codex, forced via
|
||||||
|
`clis.json` + `reloadCliRegistry()`) reads as unavailable, while an installed-and-enabled one
|
||||||
|
(claude) is unaffected by the override.
|
||||||
|
|
||||||
|
**Verified on the Debian devbox** (`codeman-devbox`, real tmux — this sandbox has none and
|
||||||
|
`WebServer`'s constructor hard-requires it): typecheck clean, the new test passes (17/17 in
|
||||||
|
`render-index-html.test.ts`), the CLI-registry suites pass (86/86), and the **full CI gate is
|
||||||
|
green — 415 test files, 7855 tests, 0 failures**.
|
||||||
|
|
||||||
|
### Launch-surface registry integration — completed
|
||||||
|
|
||||||
|
The welcome screen, desktop Run menu and mobile Run picker now use the same injected CLI catalog.
|
||||||
|
Every enabled registry entry is rendered; unavailable binaries remain hidden as before. Settings
|
||||||
|
updates the catalog and availability flags in place after enable/disable, create, edit or delete,
|
||||||
|
so the launch surfaces update without a page reload. A custom entry uses the generic quick-start
|
||||||
|
path, while stock entries retain their existing per-CLI launch settings.
|
||||||
|
|
||||||
|
Not otherwise re-verified line-by-line against every Phase 1–6 checklist item below (e.g. the
|
||||||
|
exact wording of toasts, the "same PR" sequencing notes) — the checklists are left as originally
|
||||||
|
written; treat the **Status** section above as authoritative for what exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`src/config/cli-registry/registry.ts` is READ-ONLY today, and says so in its own header comment:
|
||||||
|
|
||||||
|
> "⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file... there is no
|
||||||
|
> settings UI and no write API yet... A `seededStockIds` ratchet belongs with the write API that
|
||||||
|
> needs it."
|
||||||
|
|
||||||
|
Confirmed on `master` (2026-09-21): no `/api/clis` route exists at all (read or write);
|
||||||
|
`~/.codeman/clis.json` is hand-edit-only; `resolveInstallCommandForPlatform()` is documented
|
||||||
|
"Display text only — never executed" — nothing runs an install command server-side today. The
|
||||||
|
original #343 review flagged the opposite (`spawn(command, {shell: true})`, `env.allowedPrefixes`
|
||||||
|
contributed from a write) as needing its own trust-model decision; that decision was never made
|
||||||
|
after the split, just dropped. This plan makes it.
|
||||||
|
|
||||||
|
**Closest existing precedent, and the template this plan follows for the read/write API**:
|
||||||
|
`src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` (#393/#430/#459) — a small
|
||||||
|
per-item JSON store, Settings-UI-driven, admin-gated in multi-user mode, tmp+rename+0600 writes.
|
||||||
|
|
||||||
|
**Precedent for the new master feature flag (Phase 1)**: `customModelEndpointsEnabled` —
|
||||||
|
`z.boolean().optional()` in `SettingsUpdateSchema` (`schemas.ts:1319`), a checkbox read/written by
|
||||||
|
id in `openAppSettings()`/`saveAppSettings()` (`settings-ui.js:401`/`:2120`). SYNCED, not
|
||||||
|
per-device (present in the schema, absent from `displayKeys`), default OFF.
|
||||||
|
|
||||||
|
**Spec refs for the whole plan:**
|
||||||
|
- `src/config/cli-registry/registry.ts` — the read path; `resolveRegistry()`'s merge semantics
|
||||||
|
(`deepMerge`, `UNMERGEABLE_KEYS`) apply unchanged to whatever this plan writes
|
||||||
|
- `docs/cli-registry.md` — registry shape, "The override file", "Arg-template safety" (the four
|
||||||
|
layers Phase 5's custom-entry validation must not weaken), "Adding a CLI" (the 5-step recipe a
|
||||||
|
custom entry does NOT get to skip just because it arrives via UI instead of a stock.ts edit)
|
||||||
|
- `src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` — read/write API template
|
||||||
|
- `docs/multi-user-plan.md`, `docs/security-architecture.md` — admin-gating conventions
|
||||||
|
- `CLAUDE.md` §Multi-user mode, §"Settings surface", §"Per-device vs synced settings"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions (Phase 0, closed 2026-09-21)
|
||||||
|
|
||||||
|
1. **Enable/disable a stock CLI's `enabled` flag** — IN SCOPE. Plus a **master feature flag**
|
||||||
|
(`cliManagementEnabled`, synced, default OFF) gating the whole Settings UI section's visibility,
|
||||||
|
matching this codebase's standing convention for new admin-facing surfaces.
|
||||||
|
2. **Auto-install** (stock CLIs' already-shipped, already-vetted install commands) — IN SCOPE,
|
||||||
|
same PR.
|
||||||
|
3. **Custom CLI entries via the UI** — IN SCOPE, **typed-argv only**: a custom entry goes through
|
||||||
|
the exact same schema/argv-safety path stock entries do (named token patterns, no raw shell-text
|
||||||
|
field). Its install command stays **display-only text**, same as every stock entry today — Phase
|
||||||
|
4's auto-install NEVER executes a custom entry's install command, only a stock one's. This is
|
||||||
|
the one place scope was deliberately narrowed relative to what was agreed in principle, because
|
||||||
|
`docs/cli-registry.md`'s arg-template-safety section exists specifically to keep config free of
|
||||||
|
shell text, and a free-text install command for a user-defined entry would reopen exactly that.
|
||||||
|
4. **`shell`/`claude` un-disableable** — enforced at the **backend**, not just the UI (a
|
||||||
|
frontend-only guard is bypassable with curl).
|
||||||
|
5. **Non-admin visibility in multi-user mode** — the CLI-management Settings section is **hidden
|
||||||
|
entirely** for a non-admin, not shown-empty.
|
||||||
|
6. **`seededStockIds` ratchet** — not needed. `deepMerge()` only overrides a key the file actually
|
||||||
|
sets, so a CLI absent from `clis.json.clis` always falls through to its stock `enabled` value
|
||||||
|
with no special-casing. (Carried over from the first draft, not re-litigated.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Master feature flag: `cliManagementEnabled`
|
||||||
|
|
||||||
|
**Status:** DONE (commit `da07b38c`) — verified present in `SettingsUpdateSchema`, `index.html`,
|
||||||
|
`openAppSettings()`/`saveAppSettings()`.
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `schemas.ts:1319` (`customModelEndpointsEnabled`) — the exact pattern to mirror: `z.boolean().optional()`
|
||||||
|
in `SettingsUpdateSchema`
|
||||||
|
- `settings-ui.js:401`/`:2120` — checkbox read/write by id in `openAppSettings()`/`saveAppSettings()`
|
||||||
|
- `CLAUDE.md` §"Adding Features" → "App setting" — decide per-device vs synced FIRST (this one is
|
||||||
|
synced: a feature toggle, not a display preference) and add to `displayKeys` NEVER for a synced
|
||||||
|
setting
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] Add `cliManagementEnabled: z.boolean().optional()` to `SettingsUpdateSchema`
|
||||||
|
- [x] Add the checkbox to `index.html`'s `#settings-clis` section, above where Phase 6's per-CLI
|
||||||
|
list will render — reads/writes via `openAppSettings()`/`saveAppSettings()` by id, same as
|
||||||
|
`customModelEndpointsEnabled`
|
||||||
|
- [x] `readCliManagementEnabled()` helper (mirrors `readCustomModelEndpointsEnabled()` in
|
||||||
|
`custom-model-routes.ts:609`) for the route file(s) in Phases 2-5 to gate on
|
||||||
|
- [x] When OFF: `GET /api/clis` still exists but the Settings UI section stays hidden
|
||||||
|
(`applyCliManagementVisibility()`); the write endpoints reject (see Phase 3)
|
||||||
|
|
||||||
|
**Verify:** `npm run typecheck` passes; a unit test confirms `SettingsUpdateSchema` accepts/rejects
|
||||||
|
the field correctly; toggling it in a fresh browser profile shows/hides the Settings section with
|
||||||
|
no server restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Read endpoint: `GET /api/clis`
|
||||||
|
|
||||||
|
**Status:** DONE (commit `da07b38c`) — verified present in `src/web/routes/cli-registry-routes.ts`.
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `src/web/routes/custom-model-routes.ts:730` (`GET /api/model-endpoints`) — multi-user read
|
||||||
|
gating: empty list for a non-admin, never a 403
|
||||||
|
- `src/config/cli-registry/registry.ts` — `listClis()` (every entry, including disabled stock
|
||||||
|
ones — this is an admin/settings surface, unlike `enabledClis()`)
|
||||||
|
- `window.__codemanCliAvailable`'s resolvers (`isClaudeAvailable()` etc.) — candidate `installed`
|
||||||
|
source; confirm whether to reuse directly or the response needs its own probe (Open Question 4,
|
||||||
|
carried from the first draft — still genuinely open, decide during this phase not before)
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] New route file `cli-registry-routes.ts`
|
||||||
|
- [x] Response excludes `launch`/`env`/`capabilities`/`overlays`/`discovery`
|
||||||
|
- [x] `isMultiUserMode() && !isAdmin(req)` → `[]`
|
||||||
|
- [x] Unit tests in `test/routes/cli-registry-routes.test.ts` (admin/non-admin/single-user,
|
||||||
|
disabled stock CLI still present)
|
||||||
|
|
||||||
|
**Verify:** `npm test -- test/routes/cli-registry-routes.test.ts` passes; `curl localhost:3000/api/clis | jq`
|
||||||
|
shows every stock CLI including disabled ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Write endpoint: `PUT /api/clis/:id` (stock enable/disable)
|
||||||
|
|
||||||
|
**Status:** DONE (commit `db4557d9`) — `UNDISABLEABLE_IDS`, admin gate, tmp+rename+0600 all
|
||||||
|
confirmed present.
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `src/web/routes/custom-model-routes.ts:753` + `src/custom-model-hosts.ts:91` — write-path
|
||||||
|
template: `adminOnly` gate, read-modify-write the WHOLE file, tmp+rename+0600
|
||||||
|
- `registry.ts:47` (`filePath()` = `dataPath(...)`) and `reloadCliRegistry()` — write to the same
|
||||||
|
resolved path, invalidate the cache on every successful write or the change is invisible until
|
||||||
|
restart
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] Body: `{ enabled: boolean }`. Zod schema in `schemas.ts`
|
||||||
|
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → shell/claude guard → stock-only guard
|
||||||
|
- [x] Rejects disabling `shell` or `claude` (`UNDISABLEABLE_IDS`)
|
||||||
|
- [x] Rejects a write for an id that isn't a stock CLI
|
||||||
|
- [x] Deep-merges `{ clis: { [id]: { enabled } } }`, preserving other override keys
|
||||||
|
- [x] tmp+rename+0600 write, `reloadCliRegistry()` on success
|
||||||
|
- [x] Unit tests (`test/routes/cli-registry-routes.test.ts`)
|
||||||
|
|
||||||
|
**Verify:** `npm test` full gate green; `curl -X PUT localhost:3000/api/clis/grok -d '{"enabled":false}'`
|
||||||
|
then `GET /api/clis` shows the change with no restart; same against `shell`/`claude` returns an
|
||||||
|
error and changes nothing; `ls -la ~/.codeman/clis.json` shows mode 0600.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Auto-install: `POST /api/clis/:id/install` (stock CLIs only)
|
||||||
|
|
||||||
|
**Status:** DONE (commit `db4557d9`) — route present, `appendAdminAudit` wired in.
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `registry.ts:231` (`resolveInstallCommandForPlatform`) — currently "Display text only — never
|
||||||
|
executed"; this phase is what changes that, for stock entries only, with Decision 2's sign-off
|
||||||
|
- Original #343 review's exact concern re: `env.allowedPrefixes` contributed from a write — stays
|
||||||
|
out of scope; this phase only ever runs a command, never touches the env allowlist
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] Separate endpoint from Phase 3's toggle
|
||||||
|
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → stock-entry-only guard
|
||||||
|
- [x] `resolveInstallCommandForPlatform(entry)` for the target
|
||||||
|
- [x] Bounded execution (timeout, captured stdout/stderr)
|
||||||
|
- [x] Does NOT auto-enable on successful install
|
||||||
|
- [x] Audit-logged via `appendAdminAudit`
|
||||||
|
- [x] Unit tests
|
||||||
|
|
||||||
|
**Verify:** a real install triggered via the endpoint against a CLI not currently installed,
|
||||||
|
`GET /api/clis`'s `installed` field flips true with no restart; audit log entry present; attempting
|
||||||
|
install against a custom entry's id fails with a clear error; full CI gate green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Custom CLI entries: create / update / delete via API
|
||||||
|
|
||||||
|
**Status:** DONE (commit `db4557d9`) — `POST /api/clis`, `PUT /api/clis/custom/:id`,
|
||||||
|
`DELETE /api/clis/:id` all present. Open Question 2 resolved: a **separate** endpoint
|
||||||
|
(`PUT /api/clis/custom/:id`), not Phase 3's `PUT /api/clis/:id` widened.
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `docs/cli-registry.md` §"Arg-template safety" (all four layers), §"Adding a CLI" (the 5-step
|
||||||
|
recipe) — a custom entry created via this API must satisfy the SAME schema (`CliEntrySchema`)
|
||||||
|
every stock entry does; there is no relaxed path for UI-originated entries
|
||||||
|
- `registry.ts`'s `resolveRegistry()` — the custom-entry branch (`stock: false`, dropped with a
|
||||||
|
warning on validation failure, never falls back silently) already exists and is unchanged by
|
||||||
|
this phase; this phase only adds a way to WRITE what that branch reads
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] `POST /api/clis` (create), full `CliEntrySchema` validation
|
||||||
|
- [x] `PUT /api/clis/custom/:id` (update) — separate endpoint from Phase 3's stock toggle
|
||||||
|
- [x] `DELETE /api/clis/:id` refuses for any stock id
|
||||||
|
- [x] `id` collision check against existing stock ids
|
||||||
|
- [x] `discovery.install.command` on a custom entry stays DISPLAY-ONLY
|
||||||
|
- [x] Same tmp+rename+0600 write pattern, `reloadCliRegistry()` on every successful mutation
|
||||||
|
- [x] Unit tests
|
||||||
|
|
||||||
|
**Verify:** `npm test` full gate green; create a custom entry via curl, confirm it appears in
|
||||||
|
`GET /api/clis` — **confirm it appears in the Run menu is UNVERIFIED and currently FALSE, see
|
||||||
|
"Outstanding" above**; delete it, confirm it's gone and `clis.json` no longer references it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Settings UI
|
||||||
|
|
||||||
|
**Status:** DONE (commit `db4557d9`) — `#cliListGroup`, row rendering, toggle, Install button,
|
||||||
|
custom-entry create/edit/delete form all present in `settings-ui.js`/`index.html`. Manual browser
|
||||||
|
verification per the phase's own "Verify" step (flag on/off, non-admin hidden, toggle stops the
|
||||||
|
Run menu offering a CLI, create/enable/launch a custom entry, delete it, shell/claude undisableable)
|
||||||
|
has **not** been re-run in this session — the toggle→Run-menu leg specifically was BROKEN until the
|
||||||
|
gotcha fix above, and the create→launch leg for a custom entry is the confirmed gap in
|
||||||
|
"Outstanding".
|
||||||
|
|
||||||
|
**Spec refs:**
|
||||||
|
- `index.html:2357` (`#settings-clis`) — the existing home; Phase 1's master toggle at the top,
|
||||||
|
then the per-CLI list, then (if `cliManagementEnabled`) a "custom CLI" creation form, all above
|
||||||
|
the existing Codex-only groups
|
||||||
|
- `CLAUDE.md` §"Settings surface" — App Settings scrolls, it does not tab-switch
|
||||||
|
- `admin-ui.js` — pattern for an admin-only-VISIBLE section (not just admin-only-writable),
|
||||||
|
needed here per Decision 5
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [x] Whole section hidden when `cliManagementEnabled` is OFF, and separately hidden for a
|
||||||
|
non-admin in multi-user mode (`_applyCliManagementAdminGate`)
|
||||||
|
- [x] Fetches `GET /api/clis` when the section becomes visible; renders one row per CLI
|
||||||
|
- [x] Stock rows: enabled toggle only; `shell`/`claude` rows show the toggle disabled/greyed
|
||||||
|
- [x] Custom rows: enabled toggle plus edit/delete affordances
|
||||||
|
- [x] "Add custom CLI" form (id/label/badge/binary/argv)
|
||||||
|
- [x] Toggle/edit/delete update the row in place
|
||||||
|
|
||||||
|
**Verify:** manual browser test per `CLAUDE.md`'s "Always Test Before Deploying" rule — **not yet
|
||||||
|
re-run end-to-end in this session**; do this before considering the feature ready to ship, and
|
||||||
|
expect the custom-entry-launch step to fail until the Outstanding gap above is closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Open Questions
|
||||||
|
|
||||||
|
1. **Phase 2's `installed` source** — resolved: reuses `window.__codemanCliAvailable`'s existing
|
||||||
|
resolvers via `GET /api/clis`'s own probe (confirmed by reading the route).
|
||||||
|
2. **Phase 5's `PUT` endpoint shape** — resolved: a **separate** endpoint
|
||||||
|
(`PUT /api/clis/custom/:id`), not Phase 3's toggle route widened.
|
||||||
|
3. **Sequencing against the parked Copilot plan** — unchanged, still not blocking.
|
||||||
|
4. **NEW: custom-CLI Run-menu integration** — see "Outstanding" above. Not decided or started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Implementation is underway (see Status above); this line is left for history rather than removed —
|
||||||
|
the plan was originally approved before Phases 1–6 landed.
|
||||||
+80
-5
@@ -18,7 +18,17 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
|||||||
|
|
||||||
## The override file
|
## The override file
|
||||||
|
|
||||||
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart.
|
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read after a change made through CLI management (below).
|
||||||
|
|
||||||
|
## Managing CLIs from Settings
|
||||||
|
|
||||||
|
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||||
|
|
||||||
|
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||||
|
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
|
||||||
|
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||||
|
|
||||||
|
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||||
|
|
||||||
## The shape of an entry
|
## The shape of an entry
|
||||||
|
|
||||||
@@ -36,7 +46,9 @@ interface CliEntry {
|
|||||||
launch: CliLaunch; // the structured argv template
|
launch: CliLaunch; // the structured argv template
|
||||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||||
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
|
// .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
|
||||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -45,16 +57,71 @@ interface CliEntry {
|
|||||||
|
|
||||||
### Regexes that come from config
|
### Regexes that come from config
|
||||||
|
|
||||||
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both 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.
|
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four 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.
|
`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.
|
||||||
|
|
||||||
|
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||||
|
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||||
|
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||||
|
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||||
|
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||||
|
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||||
|
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
|
||||||
|
to comment on a page the agent published, so Claude's pattern refuses any footer that
|
||||||
|
carries it, and the idle alert goes out as usual.
|
||||||
|
|
||||||
|
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||||
|
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||||
|
the `·` its footer joins items with. Codex pins
|
||||||
|
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||||
|
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||||
|
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
||||||
|
against live panes rather than read out of a binary, which is the standard for adding a
|
||||||
|
third.
|
||||||
|
|
||||||
|
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
||||||
|
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||||
|
ultracode workflow are still running at turn end, Claude closes the turn with
|
||||||
|
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
|
||||||
|
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
|
||||||
|
the words are still on screen after the workers report back and the follow-up turn ends.
|
||||||
|
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
|
||||||
|
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
|
||||||
|
tests only the first row that starts in column 0, which is the newest transcript row. Claude
|
||||||
|
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
|
||||||
|
agent from holding its own session busy.
|
||||||
|
|
||||||
|
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||||
|
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||||
|
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||||
|
non-blank rows, which should be the part of the screen the CLI draws rather than the agent,
|
||||||
|
and the pattern should anchor on chrome only that CLI can produce. Keep the window as small
|
||||||
|
as the layout allows, since every row it adds is another row the agent may be able to write.
|
||||||
|
The label is also ANSI-stripped and length-capped at the source, and every interpolation of
|
||||||
|
it into markup goes through `escapeHtml()`, since it ends up on a badge and in an approval
|
||||||
|
card.
|
||||||
|
|
||||||
|
The two shipped entries do not sit equally well behind that rule, and the difference decides
|
||||||
|
what a pattern is allowed to do. Claude's chip is the last row, so its one-row window holds
|
||||||
|
nothing the agent can write — not even the status line above it, whose command a session
|
||||||
|
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.
|
||||||
|
|
||||||
### Three capabilities that must stay independent
|
### Three capabilities that must stay independent
|
||||||
|
|
||||||
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
||||||
|
|
||||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||||
|
|
||||||
|
## The newline chord
|
||||||
|
|
||||||
|
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||||
|
|
||||||
## Arg-template safety
|
## Arg-template safety
|
||||||
|
|
||||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||||
@@ -112,9 +179,9 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
|
|||||||
|
|
||||||
## Fields declared for later
|
## Fields declared for later
|
||||||
|
|
||||||
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
|
`accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. (`shortBadge` was on this list until the CLI management list in Settings started showing it.) They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
|
||||||
|
|
||||||
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
|
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, so re-measure before wiring one up. `accent` is the one exception: it was measured against styles.css on 2026-09-21 (method in the comment above `CLAUDE` in `stock.ts`), though nothing keeps it in step with the CSS either. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
|
||||||
|
|
||||||
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
|
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
|
||||||
|
|
||||||
@@ -186,6 +253,14 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
|||||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||||
|
|
||||||
|
## MCP server sync
|
||||||
|
|
||||||
|
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||||
|
|
||||||
|
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||||
|
|
||||||
|
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
|||||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||||
alongside `dsh`.
|
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
||||||
|
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
||||||
|
button works there too.
|
||||||
|
|
||||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||||
margin the most used community TUI, it is MIT, and it implements the status
|
margin the most used community TUI, it is MIT, and it implements the status
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
|
|||||||
|
|
||||||
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
||||||
|
|
||||||
|
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||||
|
|
||||||
|
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
||||||
|
|
||||||
## Quickest path: one-click "Run in Docker"
|
## Quickest path: one-click "Run in Docker"
|
||||||
|
|
||||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ For the Compose configuration, environment settings, storage migration, and macv
|
|||||||
|
|
||||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||||
|
|
||||||
|
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
|
||||||
|
|
||||||
|
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker Engine or Docker Desktop with Docker Compose v2
|
- Docker Engine or Docker Desktop with Docker Compose v2
|
||||||
@@ -67,7 +71,7 @@ If that directory was created by an earlier root-running image, change its owner
|
|||||||
|
|
||||||
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
||||||
|
|
||||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
|
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. For a major update, or a base-image change `Start-Codeman.sh` does not fully pick up, `docker/Update-Codeman.sh` rebuilds with no layer cache and clears the two build-artefact volumes before handing off to it (see "Major updates" in `docker/README.md`).
|
||||||
|
|
||||||
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ this file covers only what the container changes.
|
|||||||
## The short version
|
## The short version
|
||||||
|
|
||||||
| Change in the release | Applied by |
|
| Change in the release | Applied by |
|
||||||
| -------------------------------- | ------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
| Application code | The in-app updater |
|
| Application code | The in-app updater |
|
||||||
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
||||||
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
||||||
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
||||||
|
| A major update, or a Node base-image bump | `docker/Update-Codeman.sh` on the host (no-cache rebuild + fresh build volumes) |
|
||||||
|
|
||||||
The in-app updater detects all three of the bottom rows itself and refuses with a
|
The in-app updater detects the three middle rows itself and refuses with a
|
||||||
message naming what changed, so you never have to work out which case you are in.
|
message naming what changed, so you never have to work out which case you are in.
|
||||||
|
`Update-Codeman.sh` is the heavier option for when `Start-Codeman.sh` is not
|
||||||
|
enough: see "Major updates" in `docker/README.md`.
|
||||||
|
|
||||||
## Why the container needs its own path
|
## Why the container needs its own path
|
||||||
|
|
||||||
@@ -224,7 +227,10 @@ the host and the in-app path works from then on.
|
|||||||
|
|
||||||
**Resetting the build artefacts** — `docker compose down -v`, then
|
**Resetting the build artefacts** — `docker compose down -v`, then
|
||||||
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
|
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
|
||||||
image.
|
image. `docker/Update-Codeman.sh` scripts the same reset by default for the two
|
||||||
|
build-artefact volumes (`codeman-node-modules`, `codeman-dist`) only, plus an
|
||||||
|
unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not
|
||||||
|
force on its own. See "Major updates" in `docker/README.md`.
|
||||||
|
|
||||||
## Disabling it
|
## Disabling it
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,42 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
|||||||
codeman status | list | attach <path> codeman doctor
|
codeman status | list | attach <path> codeman doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Opening a session from your own page
|
||||||
|
|
||||||
|
To send someone from your page to one session, link to the dashboard with the
|
||||||
|
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
||||||
|
dashboard selects that tab when it loads. It also removes the fragment from its
|
||||||
|
own URL, so a later link to the same session still counts as a change.
|
||||||
|
|
||||||
|
Keep reusing one named window to make later links fast:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
||||||
|
```
|
||||||
|
|
||||||
|
When that window already shows the dashboard, only the fragment differs. The
|
||||||
|
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
||||||
|
reloading it. A session the window has shown before appears at once. A session
|
||||||
|
your page has only just created may not be listed yet, so the dashboard waits
|
||||||
|
for its `session:created` event and selects it then. That wait lasts at most 30
|
||||||
|
seconds: a link whose session never appears (a closed session, a typo, or in
|
||||||
|
multi-user mode another user's session) is dropped with a "Session not found"
|
||||||
|
notice. Clicking another tab, going Home or opening a web tab also ends the
|
||||||
|
wait, so a session that turns up later never takes the screen from the person.
|
||||||
|
|
||||||
|
When your page holds the window reference (`const win = window.open(...)`),
|
||||||
|
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
||||||
|
without a reload, but adds no history entry, so Back in the dashboard window
|
||||||
|
does not turn into a silent no-op.
|
||||||
|
|
||||||
|
Following a link does not count as someone looking at the session, so it
|
||||||
|
leaves the session's idle alert in place. The alert clears when the person
|
||||||
|
clicks the tab or types into the session. A link to a session that is popped
|
||||||
|
out into its own window asks that window to come forward, as clicking its tab does.
|
||||||
|
|
||||||
|
A link to `/session/<id>` opens a page showing that session alone, and that
|
||||||
|
page loads from scratch for every link.
|
||||||
|
|
||||||
## Seam 4: Hooks
|
## Seam 4: Hooks
|
||||||
|
|
||||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
|||||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||||
apply.
|
apply.
|
||||||
|
|
||||||
|
## Oversized input (issue #484)
|
||||||
|
|
||||||
|
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
|
||||||
|
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
|
||||||
|
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
|
||||||
|
#484 the client treated that like a transient failure, so an oversized paste sat
|
||||||
|
at the head of the queue, was re-sent every 2 s forever, blocked every later
|
||||||
|
input for the session, and came back from localStorage on each reload.
|
||||||
|
|
||||||
|
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||||
|
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||||
|
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||||
|
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||||
|
split), is refused with a toast and never queued.
|
||||||
|
- The WebSocket answers an oversized sequenced frame with
|
||||||
|
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||||
|
client that predates `err` reads it as a plain ACK and drops it too.
|
||||||
|
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
|
||||||
|
an expired login delivers once the user signs in again).
|
||||||
|
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
|
||||||
|
poisoned by an older build heals on the first load after upgrading.
|
||||||
|
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
|
||||||
|
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
|
||||||
|
`test/input-size-limit.test.ts`.
|
||||||
|
|
||||||
## Known limitation
|
## Known limitation
|
||||||
|
|
||||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||||
@@ -79,3 +104,5 @@ across the narrow restart window.
|
|||||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||||
|
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
|
||||||
|
splitting, and dropping (never retrying) a frame refused for good (#484).
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
|||||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||||
|
|
||||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, and, only when their opt-in switches `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` are `1`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||||
@@ -516,6 +516,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
|||||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||||
|
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
|
||||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -531,6 +532,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10c. Webhook notifications (outbound channel)
|
||||||
|
|
||||||
|
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||||
|
|
||||||
|
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||||
|
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||||
|
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Quick reference
|
## 11. Quick reference
|
||||||
|
|
||||||
| Env / flag | Effect |
|
| Env / flag | Effect |
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
|||||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||||
is kept working for the bundled UI.
|
is kept working for the bundled UI.
|
||||||
|
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
||||||
|
`#session=<id>` fragment selects that session if this client can see it. The
|
||||||
|
fragment name and that meaning are stable; see
|
||||||
|
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
||||||
|
|
||||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent.
|
|||||||
| Read My Mind | Yes | No |
|
| Read My Mind | Yes | No |
|
||||||
| Ralph loop and its task tracker | Yes | No |
|
| Ralph loop and its task tracker | Yes | No |
|
||||||
| Subagent and team windows | Yes | No |
|
| Subagent and team windows | Yes | No |
|
||||||
| Model, effort, and ultracode controls | 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; elsewhere 400 if you ask for them explicitly |
|
||||||
| The bundled agent skill | Yes | No |
|
| The bundled agent skill | Yes | No |
|
||||||
|
|
||||||
@@ -96,6 +96,9 @@ The defaults you will care about, all under **App Settings**:
|
|||||||
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
||||||
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
||||||
environment variable, because that would hard-lock it and block in-session switching.
|
environment variable, because that would hard-lock it and block in-session switching.
|
||||||
|
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
|
||||||
|
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
|
||||||
|
default: `/advisor` switches it or turns it off inside the session.
|
||||||
- **Startup permission mode** (Agents & CLIs section). The default is
|
- **Startup permission mode** (Agents & CLIs section). The default is
|
||||||
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
||||||
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
||||||
|
|||||||
@@ -42,10 +42,17 @@ npm run typecheck
|
|||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax
|
npm run check:frontend-syntax
|
||||||
|
npm run check:browser-excludes
|
||||||
npm test -- test/<file>.test.ts # one file, the normal way
|
npm test -- test/<file>.test.ts # one file, the normal way
|
||||||
npm run test:ci # the full CI sweep
|
npm run test:ci # the full CI sweep
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||||
|
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
||||||
|
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
||||||
|
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||||
|
`pre-push` hook of your own is never overwritten.
|
||||||
|
|
||||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ Three ways to get one, all under **+** next to the case picker:
|
|||||||
| How | Result |
|
| How | Result |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
||||||
| **Clone Repo** | A public repo cloned into `~/codeman-cases/<name>` and registered as a case. |
|
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||||
|
|
||||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||||
registration, and for a linked case that is all it removes.
|
registration, and for a linked case that is all it removes.
|
||||||
|
|
||||||
|
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
|
||||||
|
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
|
||||||
|
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
|
||||||
|
then signing those CLIs in once from a shell session is enough. See the private repositories
|
||||||
|
section of `docker/README.md`. Without credentials a private repo fails straight away with an
|
||||||
|
authentication error.
|
||||||
|
|
||||||
**Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not
|
**Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not
|
||||||
delete `~/codeman-cases/`, but treat that directory as real work, not scratch space.
|
delete `~/codeman-cases/`, but treat that directory as real work, not scratch space.
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,41 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
|
|||||||
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
||||||
Codeman reads it host-side for history and resume.
|
Codeman reads it host-side for history and resume.
|
||||||
|
|
||||||
|
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
|
||||||
|
with the `azure-devops` extension), off by default, and its git then uses them as credential
|
||||||
|
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
|
||||||
|
seeded like everything else, file by file: `~/.config/gh/hosts.yml` and `config.yml`, and the
|
||||||
|
sign-in files from `~/.azure` (not its logs or extensions). With a switch off they are never
|
||||||
|
copied in, even if the files exist. So once a switch is on and `gh auth login` / `az login`
|
||||||
|
have been run where Codeman runs, agents in a Docker case can clone and push private repos on
|
||||||
|
those hosts. Two limits:
|
||||||
|
|
||||||
|
- A token held in a desktop keyring or an encrypted token cache (Windows, macOS) is not
|
||||||
|
inside those files and does not carry in. Sign in inside the container instead. The Docker
|
||||||
|
server image and a headless Linux host keep it in the files, so they carry.
|
||||||
|
- The sign-ins are mounted when a case container is **created**, so an existing container
|
||||||
|
never picks them up. After turning a switch on, signing in, or rebuilding the agent image,
|
||||||
|
**recreate the case container**: remove it, and the next session in that case creates a
|
||||||
|
fresh one. (Or sign in inside the existing container instead.)
|
||||||
|
|
||||||
|
This hands a GitHub token and an Azure sign-in to every agent in a seeded Docker case, the
|
||||||
|
same trust you already give it with Claude, Codex or gcloud. Turn seeding off for a case that
|
||||||
|
should not have them.
|
||||||
|
|
||||||
|
Both CLIs are opt-in. To build the agent image with them, set
|
||||||
|
`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` and/or `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` where the image
|
||||||
|
is built: in front of `node scripts/build-agent-image.mjs`, or in the Codeman server's
|
||||||
|
environment for the image it builds automatically (in the Docker deployment, `environment:`
|
||||||
|
in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
||||||
|
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||||
|
the server image.
|
||||||
|
|
||||||
|
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
|
||||||
|
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
|
||||||
|
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
|
||||||
|
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
|
||||||
|
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
|
||||||
|
|
||||||
## Isolation
|
## Isolation
|
||||||
|
|
||||||
Every container runs hardened by default:
|
Every container runs hardened by default:
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
|||||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||||
curl -s "$API/api/subagents" | jq # background agents
|
curl -s "$API/api/subagents" | jq # background agents
|
||||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||||
|
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||||
|
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||||
|
|
||||||
# with ID set to a session id:
|
# with ID set to a session id:
|
||||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ opening the session.
|
|||||||
## The signals, cheapest first
|
## The signals, cheapest first
|
||||||
|
|
||||||
| Surface | Reaches you | Default |
|
| Surface | Reaches you | Default |
|
||||||
| ---------------------- | ------------------------------------------------- | ------- |
|
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||||
| Tab alert | While the dashboard is open | On |
|
| Tab alert | While the dashboard is open | On |
|
||||||
| Browser title flash | Another tab in the same browser | On |
|
| Browser title flash | Another tab in the same browser | On |
|
||||||
| Desktop notification | Another window on the same machine | Opt-in |
|
| Desktop notification | Another window on the same machine | Opt-in |
|
||||||
| Push notification | Anywhere, even with no tab open | 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 across every session | Opt-in |
|
||||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||||
@@ -60,6 +61,51 @@ Setup:
|
|||||||
|
|
||||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||||
|
|
||||||
|
## Webhooks: ntfy, Slack, Discord
|
||||||
|
|
||||||
|
**Opt-in, off by default. One channel for the whole server.**
|
||||||
|
|
||||||
|
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||||
|
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||||
|
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||||
|
and for a team channel.
|
||||||
|
|
||||||
|
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||||
|
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||||
|
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||||
|
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||||
|
|
||||||
|
Setup, in **App Settings → Notifications → Webhook**:
|
||||||
|
|
||||||
|
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||||
|
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||||
|
sessionId, sessionName, host, at }`.
|
||||||
|
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||||
|
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||||
|
Send test saves anything you changed first, so it always tests what is on screen.
|
||||||
|
|
||||||
|
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||||
|
(an HTTP status, a timeout, a refused connection).
|
||||||
|
|
||||||
|
Behaviour worth knowing:
|
||||||
|
|
||||||
|
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||||
|
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||||
|
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||||
|
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||||
|
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||||
|
(which also turns the channel off).
|
||||||
|
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||||
|
the only thing keeping strangers out.
|
||||||
|
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||||
|
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||||
|
message is sent, and redirects are not followed.
|
||||||
|
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||||
|
once, so a flapping prompt cannot flood a channel.
|
||||||
|
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||||
|
the webhook, and it receives every user's session events (session names, tool names, error
|
||||||
|
text). Point it somewhere every user would be comfortable with.
|
||||||
|
|
||||||
## The Approvals Inbox
|
## The Approvals Inbox
|
||||||
|
|
||||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||||
@@ -103,6 +149,34 @@ locked phone and the agent continues.
|
|||||||
With the inbox off, the buttons are stripped from the notification payload entirely rather
|
With the inbox off, the buttons are stripped from the notification payload entirely rather
|
||||||
than being shown and failing.
|
than being shown and failing.
|
||||||
|
|
||||||
|
## When a session is watching its own work
|
||||||
|
|
||||||
|
An agent that starts a monitor, puts a shell in the background or hands a task to a cloud
|
||||||
|
session is told by its CLI to end the turn and wait to be notified. The pane then goes
|
||||||
|
quiet, and the CLI's idle notification arrives about a minute later — for a session that
|
||||||
|
wants nothing from you.
|
||||||
|
|
||||||
|
Codeman reads what the CLI prints about its own background work and treats that prompt
|
||||||
|
differently. It raises no tab alert, no desktop notification and no push, the session stays
|
||||||
|
out of NEEDS YOU on every surface, and the row wears a blue **watching** badge instead. Hover
|
||||||
|
it, or read it on a phone through your screen reader, and it says what is running: "1
|
||||||
|
monitor", "2 shells", "1 background terminal".
|
||||||
|
|
||||||
|
The prompt itself is not thrown away. It sits in the Approvals drawer as an ordinary card,
|
||||||
|
still answerable, with a line reading "quiet, watching 1 monitor" where a card you had
|
||||||
|
already looked at would say nothing. The next time that session goes quiet for an ordinary
|
||||||
|
reason, it alerts you exactly as before.
|
||||||
|
|
||||||
|
Two limits are worth knowing. A permission prompt or a question dialog still goes red
|
||||||
|
whatever else the agent started, because that one blocks it outright. A question asked in
|
||||||
|
plain prose is not a dialog, so an agent that starts a monitor and then writes "which branch
|
||||||
|
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||||
|
been quiet longer than the work it is waiting for should take.
|
||||||
|
|
||||||
|
An agent waiting for your comments on an artifact it published never counts as watching.
|
||||||
|
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
|
||||||
|
comment, so the session alerts you like any other quiet session.
|
||||||
|
|
||||||
## The phone overview
|
## The phone overview
|
||||||
|
|
||||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||||
@@ -124,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in
|
|||||||
## Recommended setup for unattended runs
|
## Recommended setup for unattended runs
|
||||||
|
|
||||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||||
|
webhook to ntfy if no browser will ever be open.
|
||||||
3. Approvals Inbox on.
|
3. Approvals Inbox on.
|
||||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||||
[Keeping Agents Running](Keeping-Agents-Running).
|
[Keeping Agents Running](Keeping-Agents-Running).
|
||||||
@@ -134,7 +209,8 @@ from the lock screen.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||||
|
has no such requirement, since the server sends it.
|
||||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
- **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.
|
- **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
|
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
|||||||
| Tab | Use it when |
|
| Tab | Use it when |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
||||||
| **Clone Repo** | Working on an existing public repo. Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||||
|
|
||||||
The gear next to the picker holds two per-case toggles: **Agent Teams** and
|
The gear next to the picker holds two per-case toggles: **Agent Teams** and
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
||||||
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
||||||
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
|
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
|
||||||
|
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
|
||||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||||
|
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
|
||||||
|
|
||||||
### Header & Panels
|
### Header & Panels
|
||||||
|
|
||||||
@@ -60,6 +62,9 @@ Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultrac
|
|||||||
Ultracode Windows, Cron.
|
Ultracode Windows, Cron.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||||
|
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
|
||||||
|
(default; label over value with a bar underneath), *Compact* (one pill with sparklines plus
|
||||||
|
plan-usage rings) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||||
resizable panes plus their divider have nowhere to go.
|
resizable panes plus their divider have nowhere to go.
|
||||||
@@ -77,7 +82,8 @@ every session or only the active tab.
|
|||||||
| Interface Language | English or Simplified Chinese. Per device. |
|
| Interface Language | English or Simplified Chinese. Per device. |
|
||||||
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
||||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
| Tab Grouping | *By state* (default) splits the tabs into needs you, waiting, working and idle: rows in the header, sections in the rail and sidebar. *None* keeps one list in tab order. See [The Dashboard](The-Dashboard#tab-grouping-by-state). |
|
||||||
|
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With grouping by state it orders the rows inside each section. |
|
||||||
| Tall Tabs | Taller tab strip. |
|
| Tall Tabs | Taller tab strip. |
|
||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||||
@@ -86,13 +92,22 @@ every session or only the active tab.
|
|||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
|
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||||
and the switch compose into one model choice, so there is no separate "which one wins"
|
advisor segment. The cards and the switch compose into one model choice, so there is no
|
||||||
question.
|
separate "which one wins" question.
|
||||||
|
|
||||||
Model and effort are both **soft defaults**: the model is written into the case's
|
Model, effort and advisor are all **soft defaults**: the model is written into the case's
|
||||||
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
|
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
|
||||||
inside a session override them at any time.
|
`/effort` and `/advisor` inside a session override them at any time.
|
||||||
|
|
||||||
|
**Advisor** gives new Claude sessions Claude Code's
|
||||||
|
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
|
||||||
|
consults before committing to an approach, when an error keeps coming back, and before it
|
||||||
|
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
|
||||||
|
which costs less than running the stronger model all the time. **Default** leaves it to
|
||||||
|
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
|
||||||
|
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
|
||||||
|
attached.
|
||||||
|
|
||||||
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
||||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||||
@@ -111,11 +126,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
|||||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||||
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
||||||
| Animated status effects | Cosmetic. |
|
| Animated status effects | Cosmetic. |
|
||||||
|
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Master toggle, browser notifications, push subscription, audio alerts, and the idle
|
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
||||||
threshold that decides when a quiet session counts as needing you. See
|
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||||
|
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||||
[Notifications And Approvals](Notifications-And-Approvals).
|
[Notifications And Approvals](Notifications-And-Approvals).
|
||||||
|
|
||||||
### Voice
|
### Voice
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
|||||||
|
|
||||||
| Layout | Behaviour |
|
| Layout | Behaviour |
|
||||||
| -------------------- | --------------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------- |
|
||||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
| **Header tab strip** | The default. On desktop it is one row per state (see [Tab grouping by state](#tab-grouping-by-state)); it scrolls sideways on a phone. |
|
||||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
|
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
|
||||||
|
|
||||||
@@ -35,6 +35,31 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
|
|||||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||||
|
|
||||||
|
## Tab grouping by state
|
||||||
|
|
||||||
|
By default the tabs are grouped by what each session needs from you, most urgent on top:
|
||||||
|
|
||||||
|
| Group | Who is in it |
|
||||||
|
| ------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||||
|
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||||
|
| **Working** | A turn is running. |
|
||||||
|
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||||
|
|
||||||
|
In the header each group is a row with its name and count on the left; a group with more
|
||||||
|
tabs than fit on one line continues under its own tabs. The vertical rail and the left
|
||||||
|
sidebar show the same groups as sections. Empty groups are not shown. These are the same
|
||||||
|
states the phone overview and the desktop home rail use.
|
||||||
|
|
||||||
|
Tabs move between groups on their own as their state changes. Inside a group they keep your
|
||||||
|
tab order (on a rail sorted *By activity*, the activity order), and the `Alt+1` to `Alt+9`
|
||||||
|
numbers never change. Dragging reorders tabs within a group. On a phone the strip stays a
|
||||||
|
single scrolling row: the tabs come in group order, without the headings. If you have named
|
||||||
|
tab groups in the vertical rail, those take precedence there.
|
||||||
|
|
||||||
|
Turn it off with **App Settings → Appearance → Tabs → Tab Grouping → None** to get one list
|
||||||
|
in tab order. Per device.
|
||||||
|
|
||||||
## Session tabs
|
## Session tabs
|
||||||
|
|
||||||
One tab per session, in your order, and that order syncs across your devices.
|
One tab per session, in your order, and that order syncs across your devices.
|
||||||
@@ -48,6 +73,7 @@ One tab per session, in your order, and that order syncs across your devices.
|
|||||||
| Yellow tab, blinking | The agent is waiting for input from you. |
|
| Yellow tab, blinking | The agent is waiting for input from you. |
|
||||||
| Red tab, blinking | A question or permission prompt is blocking the session. |
|
| Red tab, blinking | A question or permission prompt is blocking the session. |
|
||||||
| No dot | The session is not running. |
|
| No dot | The session is not running. |
|
||||||
|
| Muted grey dot plus an `exited (137)` badge | The agent inside the pane has exited, with that exit code (or `exited (signal 9)`). A bare `exited` means tmux saw the pane die but did not report how, which is not the same as a clean `exited (0)`. Detailed sidebar and rail rows read `exited` in their pill. |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -101,7 +127,7 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||||
| CPU / MEM bars | On | Server resource use. |
|
| CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
|
||||||
| File Viewer | On | Toggles the file browser panel. |
|
| File Viewer | On | Toggles the file browser panel. |
|
||||||
| Settings gear | Always on | App Settings. |
|
| Settings gear | Always on | App Settings. |
|
||||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||||
@@ -120,6 +146,19 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||||
| Admin panel | Multi-user only | User administration. |
|
| Admin panel | Multi-user only | User administration. |
|
||||||
|
|
||||||
|
### Header Stats Style
|
||||||
|
|
||||||
|
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
|
||||||
|
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
|
||||||
|
|
||||||
|
| Style | Look |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| **Tiles** | The default. One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
|
||||||
|
| **Compact** | One pill with `WS · CPU · MEM` and a tiny sparkline of the last few samples, then a pill with a ring per plan window. Hands the tabs back the most room. |
|
||||||
|
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
|
||||||
|
|
||||||
|
Hiding System Stats or Plan Usage still hides them in every style.
|
||||||
|
|
||||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||||
covered in [Mobile Guide](Mobile-Guide).
|
covered in [Mobile Guide](Mobile-Guide).
|
||||||
|
|
||||||
@@ -150,10 +189,13 @@ Worth knowing:
|
|||||||
|
|
||||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
||||||
and automatic output recovery stay within the bounded browser buffer.
|
history; press **Load full history** to pull the rest explicitly. Automatic output
|
||||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
recovery stays within the bounded browser buffer.
|
||||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||||
|
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||||
|
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||||
|
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||||
always local scrollback. Other CLIs scroll locally.
|
always local scrollback. Other CLIs scroll locally.
|
||||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||||
`Ctrl+Shift+C` always copies.
|
`Ctrl+Shift+C` always copies.
|
||||||
|
|||||||
@@ -164,8 +164,11 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
|
|||||||
Things to try:
|
Things to try:
|
||||||
|
|
||||||
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
||||||
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
|
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
|
||||||
so it scrolls the conversation rather than the terminal buffer. That is intended.
|
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
|
||||||
|
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
|
||||||
|
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
|
||||||
|
`~/.claude/settings.json`.
|
||||||
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
||||||
|
|
||||||
### The wheel does nothing in a Codex session
|
### The wheel does nothing in a Codex session
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ It renders what it can:
|
|||||||
|
|
||||||
| Kind | Behaviour |
|
| Kind | Behaviour |
|
||||||
| ------------------------ | ------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------- |
|
||||||
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
|
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
||||||
|
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||||
| Images | Inline. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
@@ -85,8 +86,9 @@ File paths in a session are links. That works in two places:
|
|||||||
render as underlined monospace links.
|
render as underlined monospace links.
|
||||||
|
|
||||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||||
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
|
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
|
||||||
the tail viewer instead, which follows a file that is still being written.
|
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
||||||
|
viewer instead, which follows a file that is still being written.
|
||||||
|
|
||||||
Paths **outside** the session's workspace work too, which matters because that is where most
|
Paths **outside** the session's workspace work too, which matters because that is where most
|
||||||
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
||||||
|
|||||||
+1
-1
@@ -169,7 +169,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
|||||||
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
||||||
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
||||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
||||||
CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
|
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
|
||||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
|
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
|
||||||
|
|||||||
Generated
+17
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.32.0",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.32.0",
|
"version": "1.34.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"smol-toml": "^1.9.0",
|
||||||
"undici": "^6.28.0",
|
"undici": "^6.28.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
@@ -10113,6 +10115,18 @@
|
|||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/smol-toml": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cyyynthia"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.9",
|
"version": "2.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||||
@@ -12371,7 +12385,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/xterm-zerolag-input": {
|
"packages/xterm-zerolag-input": {
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.32.0",
|
"version": "1.34.0",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||||
|
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"smol-toml": "^1.9.0",
|
||||||
"undici": "^6.28.0",
|
"undici": "^6.28.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# xterm-zerolag-input
|
# xterm-zerolag-input
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6aecc3b: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||||
|
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||||
|
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||||
|
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||||
|
|
||||||
|
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||||
|
|
||||||
|
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||||
|
|
||||||
|
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||||
|
|
||||||
|
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||||
|
|
||||||
|
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||||
|
|
||||||
|
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||||
|
|
||||||
|
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||||
|
|
||||||
|
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ terminal.onData((data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
|
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
|
||||||
terminal.onWriteParsed(() => {
|
// Unconditional: rerender() is a no-op when there is nothing to draw, and
|
||||||
if (zerolag.hasPending) zerolag.rerender();
|
// hasPending would miss an overlay that shows only an IME composition.
|
||||||
});
|
terminal.onWriteParsed(() => zerolag.rerender());
|
||||||
```
|
```
|
||||||
|
|
||||||
That is the whole integration. Everything below is for tuning it.
|
That is the whole integration. Everything below is for tuning it.
|
||||||
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
|
|||||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||||
```
|
```
|
||||||
|
|
||||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||||
|
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||||
|
|
||||||
### Backspace handling
|
### Backspace handling
|
||||||
|
|
||||||
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
|||||||
|
|
||||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||||
|
|
||||||
|
### IME composition
|
||||||
|
|
||||||
|
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const textarea = terminal.textarea!;
|
||||||
|
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
|
||||||
|
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
|
||||||
|
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
|
||||||
|
```
|
||||||
|
|
||||||
|
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
|
||||||
|
|
||||||
### Flushed text
|
### Flushed text
|
||||||
|
|
||||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||||
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
|||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
|
||||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||||
|
|
||||||
### Prompt
|
### Prompt
|
||||||
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
|||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
|
||||||
|
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
|
||||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "xterm-zerolag-input",
|
"name": "xterm-zerolag-input",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
|||||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||||
const {
|
const {
|
||||||
lines,
|
lines,
|
||||||
|
compositionStart,
|
||||||
startCol,
|
startCol,
|
||||||
totalCols,
|
totalCols,
|
||||||
cellW,
|
cellW,
|
||||||
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||||
// dropped along with that line when the tail is all that fits.
|
// dropped along with that line when the tail is all that fits.
|
||||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||||
|
// Code-point offset of each line in the whole text, so the composition
|
||||||
|
// styling survives the tail slice below.
|
||||||
|
const lineOffsets: number[] = [];
|
||||||
|
{
|
||||||
|
let offset = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
lineOffsets.push(offset);
|
||||||
|
offset += [...line].length;
|
||||||
|
}
|
||||||
|
}
|
||||||
let visibleLines = lines;
|
let visibleLines = lines;
|
||||||
|
let firstVisible = 0;
|
||||||
let keepsPromptLine = true;
|
let keepsPromptLine = true;
|
||||||
let topRow = promptRow;
|
let topRow = promptRow;
|
||||||
if (rows && rows > 0) {
|
if (rows && rows > 0) {
|
||||||
if (lines.length > rows) {
|
if (lines.length > rows) {
|
||||||
visibleLines = lines.slice(lines.length - rows);
|
firstVisible = lines.length - rows;
|
||||||
|
visibleLines = lines.slice(firstVisible);
|
||||||
keepsPromptLine = false;
|
keepsPromptLine = false;
|
||||||
topRow = 0;
|
topRow = 0;
|
||||||
} else if (promptRow + lines.length > rows) {
|
} else if (promptRow + lines.length > rows) {
|
||||||
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
const leftPx = indents ? startCol * cellW : 0;
|
const leftPx = indents ? startCol * cellW : 0;
|
||||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||||
const topPx = i * cellH;
|
const topPx = i * cellH;
|
||||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
const lineCompositionFrom =
|
||||||
|
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||||
|
const lineEl = makeLine(
|
||||||
|
visibleLines[i],
|
||||||
|
leftPx,
|
||||||
|
topPx,
|
||||||
|
widthPx,
|
||||||
|
cellH,
|
||||||
|
cellW,
|
||||||
|
charTop,
|
||||||
|
charHeight,
|
||||||
|
font,
|
||||||
|
terminal,
|
||||||
|
lineCompositionFrom
|
||||||
|
);
|
||||||
container.appendChild(lineEl);
|
container.appendChild(lineEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
* Create a styled line `<div>` with per-character grid positioning.
|
* Create a styled line `<div>` with per-character grid positioning.
|
||||||
*
|
*
|
||||||
* Each character gets its own `<span>` positioned by visual column offset.
|
* Each character gets its own `<span>` positioned by visual column offset.
|
||||||
* CJK wide characters occupy 2 cell widths.
|
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||||
|
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||||
|
* composition text: underlined, like xterm's own composition view, and marked
|
||||||
|
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||||
*/
|
*/
|
||||||
function makeLine(
|
function makeLine(
|
||||||
text: string,
|
text: string,
|
||||||
@@ -156,7 +186,8 @@ function makeLine(
|
|||||||
_charTop: number,
|
_charTop: number,
|
||||||
_charHeight: number,
|
_charHeight: number,
|
||||||
font: FontStyle,
|
font: FontStyle,
|
||||||
terminal?: XtermTerminal | null
|
terminal?: XtermTerminal | null,
|
||||||
|
compositionFrom?: number
|
||||||
): HTMLDivElement {
|
): HTMLDivElement {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||||
@@ -172,6 +203,7 @@ function makeLine(
|
|||||||
|
|
||||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||||
let colOffset = 0;
|
let colOffset = 0;
|
||||||
|
let index = 0;
|
||||||
for (const ch of text) {
|
for (const ch of text) {
|
||||||
const cw = charCellWidth(terminal, ch);
|
const cw = charCellWidth(terminal, ch);
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
@@ -189,9 +221,15 @@ function makeLine(
|
|||||||
span.style.fontWeight = font.fontWeight;
|
span.style.fontWeight = font.fontWeight;
|
||||||
span.style.color = font.color;
|
span.style.color = font.color;
|
||||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||||
|
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||||
|
span.style.textDecoration = 'underline';
|
||||||
|
span.setAttribute('data-zerolag-composition', '');
|
||||||
|
span.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
span.textContent = ch;
|
span.textContent = ch;
|
||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
colOffset += cw;
|
colOffset += cw;
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ export interface CellDimensions {
|
|||||||
/** Parameters for the overlay renderer. */
|
/** Parameters for the overlay renderer. */
|
||||||
export interface RenderParams {
|
export interface RenderParams {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
|
/**
|
||||||
|
* Index (in code points, across all `lines`) where IME composition text
|
||||||
|
* begins. Characters from there on are drawn underlined and marked
|
||||||
|
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||||
|
*/
|
||||||
|
compositionStart?: number;
|
||||||
startCol: number;
|
startCol: number;
|
||||||
totalCols: number;
|
totalCols: number;
|
||||||
cellW: number;
|
cellW: number;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
private _flushedOffset = 0;
|
private _flushedOffset = 0;
|
||||||
private _flushedText = '';
|
private _flushedText = '';
|
||||||
private _bufferDetectDone = false;
|
private _bufferDetectDone = false;
|
||||||
|
// IME text still being composed: drawn after the pending text, never sent.
|
||||||
|
private _composition = '';
|
||||||
|
|
||||||
// Render cache
|
// Render cache
|
||||||
private _lastRenderKey = '';
|
private _lastRenderKey = '';
|
||||||
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
clearTimeout(this._scrollTimer);
|
clearTimeout(this._scrollTimer);
|
||||||
this._scrollTimer = null;
|
this._scrollTimer = null;
|
||||||
}
|
}
|
||||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
} else if (this._hasContent()) {
|
||||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||||
this._scrollTimer = setTimeout(() => {
|
this._scrollTimer = setTimeout(() => {
|
||||||
this._scrollTimer = null;
|
this._scrollTimer = null;
|
||||||
@@ -206,8 +208,14 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
||||||
* The consumer SHOULD send backspace to the PTY.
|
* The consumer SHOULD send backspace to the PTY.
|
||||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||||
|
*
|
||||||
|
* Any IME composition is dropped in every case, and the overlay is repainted
|
||||||
|
* without it (hidden when nothing else is left).
|
||||||
*/
|
*/
|
||||||
removeChar(): 'pending' | 'flushed' | false {
|
removeChar(): 'pending' | 'flushed' | false {
|
||||||
|
// A backspace that reaches the overlay means no composition is open.
|
||||||
|
const droppedComposition = this._composition.length > 0;
|
||||||
|
this._composition = '';
|
||||||
if (this._pendingText.length > 0) {
|
if (this._pendingText.length > 0) {
|
||||||
this._pendingText = this._pendingText.slice(0, -1);
|
this._pendingText = this._pendingText.slice(0, -1);
|
||||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||||
@@ -243,6 +251,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
return 'flushed';
|
return 'flushed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing to remove, but a composition-only overlay is still on screen
|
||||||
|
// drawing the text dropped above.
|
||||||
|
if (droppedComposition) this._hide();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +263,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this._pendingText = '';
|
this._pendingText = '';
|
||||||
|
this._composition = '';
|
||||||
this._flushedOffset = 0;
|
this._flushedOffset = 0;
|
||||||
this._flushedText = '';
|
this._flushedText = '';
|
||||||
this._bufferDetectDone = false;
|
this._bufferDetectDone = false;
|
||||||
@@ -297,7 +309,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
clearFlushed(): void {
|
clearFlushed(): void {
|
||||||
this._flushedOffset = 0;
|
this._flushedOffset = 0;
|
||||||
this._flushedText = '';
|
this._flushedText = '';
|
||||||
if (this._pendingText) {
|
if (this._pendingText || this._composition) {
|
||||||
this._render();
|
this._render();
|
||||||
} else {
|
} else {
|
||||||
this._hide();
|
this._hide();
|
||||||
@@ -312,7 +324,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
* that move the prompt.
|
* that move the prompt.
|
||||||
*/
|
*/
|
||||||
rerender(): void {
|
rerender(): void {
|
||||||
if (this._pendingText || this._flushedOffset > 0) {
|
if (this._hasContent()) {
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
@@ -325,7 +337,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
refreshFont(): void {
|
refreshFont(): void {
|
||||||
this._cacheFont();
|
this._cacheFont();
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
if (this._hasContent()) this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Buffer detection ─────────────────────────────────────────────
|
// ─── Buffer detection ─────────────────────────────────────────────
|
||||||
@@ -391,7 +403,37 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
this._options.prompt = finder;
|
this._options.prompt = finder;
|
||||||
this._lastPromptPos = null;
|
this._lastPromptPos = null;
|
||||||
this._lastRenderKey = '';
|
this._lastRenderKey = '';
|
||||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
if (this._hasContent()) this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IME composition ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show text an IME is still composing as an underlined tail after the
|
||||||
|
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||||
|
* Pass `''` to remove it.
|
||||||
|
*
|
||||||
|
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||||
|
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||||
|
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||||
|
* composition. `clear()` and `removeChar()` drop it too.
|
||||||
|
*/
|
||||||
|
setComposition(text: string): void {
|
||||||
|
// One visual line of provisional text: control characters and line breaks
|
||||||
|
// would break the cell grid.
|
||||||
|
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||||
|
if (next === this._composition) return;
|
||||||
|
this._composition = next;
|
||||||
|
if (this._hasContent()) {
|
||||||
|
this._render();
|
||||||
|
} else {
|
||||||
|
this._hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||||
|
get composition(): string {
|
||||||
|
return this._composition;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||||
@@ -425,7 +467,13 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
return this._pendingText;
|
return this._pendingText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether there is any overlay content (pending or flushed). */
|
/**
|
||||||
|
* Whether there is pending or flushed text. Excludes the IME composition,
|
||||||
|
* which is never sent, so an overlay showing only a composition reports
|
||||||
|
* `false` while still on screen. To re-place the overlay after output or a
|
||||||
|
* resize, call `rerender()` unconditionally: it is a no-op when there is
|
||||||
|
* nothing to draw.
|
||||||
|
*/
|
||||||
get hasPending(): boolean {
|
get hasPending(): boolean {
|
||||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||||
}
|
}
|
||||||
@@ -443,6 +491,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
// ─── Private methods ──────────────────────────────────────────────
|
// ─── Private methods ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private _hasContent(): boolean {
|
||||||
|
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private _getPromptOffset(): number {
|
private _getPromptOffset(): number {
|
||||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||||
return prompt.offset ?? 2;
|
return prompt.offset ?? 2;
|
||||||
@@ -505,7 +557,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
private _render(): void {
|
private _render(): void {
|
||||||
if (!this._terminal || !this._overlay) return;
|
if (!this._terminal || !this._overlay) return;
|
||||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
if (!this._hasContent()) {
|
||||||
this._overlay.style.display = 'none';
|
this._overlay.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -563,12 +615,16 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The composition is a styled tail after everything the user has typed.
|
||||||
|
const compositionStart = [...displayText].length;
|
||||||
|
displayText += this._composition;
|
||||||
|
|
||||||
// Skip redundant re-renders — include text content to detect
|
// Skip redundant re-renders — include text content to detect
|
||||||
// same-length changes (e.g., setFlushed with different text)
|
// same-length changes (e.g., setFlushed with different text)
|
||||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||||
// changing the text — must not be skipped as a redundant render.
|
// changing the text — must not be skipped as a redundant render.
|
||||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||||
this._lastRenderKey = renderKey;
|
this._lastRenderKey = renderKey;
|
||||||
|
|
||||||
@@ -608,6 +664,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
renderOverlay(this._overlay, {
|
renderOverlay(this._overlay, {
|
||||||
lines,
|
lines,
|
||||||
|
compositionStart: this._composition ? compositionStart : undefined,
|
||||||
startCol,
|
startCol,
|
||||||
totalCols,
|
totalCols,
|
||||||
cellW,
|
cellW,
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { createMockTerminal } from './helpers.js';
|
||||||
|
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||||
|
|
||||||
|
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||||
|
// after the pending text. Visual only, never part of what a consumer sends.
|
||||||
|
|
||||||
|
const CELL_W = 10;
|
||||||
|
|
||||||
|
let cleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const fn of cleanups) fn();
|
||||||
|
cleanups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||||||
|
const mock = createMockTerminal({
|
||||||
|
buffer: { lines: opts.lines ?? ['$ '] },
|
||||||
|
cols: opts.cols,
|
||||||
|
rows: opts.rows,
|
||||||
|
cellWidth: CELL_W,
|
||||||
|
cellHeight: 20,
|
||||||
|
});
|
||||||
|
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
|
||||||
|
mock.terminal.loadAddon(addon);
|
||||||
|
cleanups.push(() => {
|
||||||
|
addon.dispose();
|
||||||
|
mock.cleanup();
|
||||||
|
});
|
||||||
|
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
|
||||||
|
return { addon, mock, overlay };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||||||
|
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||||||
|
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineText(line: HTMLDivElement): string {
|
||||||
|
return Array.from(line.children)
|
||||||
|
.map((s) => s.textContent)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositionText(overlay: HTMLDivElement): string {
|
||||||
|
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||||||
|
.map((s) => s.textContent)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setComposition', () => {
|
||||||
|
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
|
||||||
|
const [line] = lineDivs(overlay);
|
||||||
|
expect(lineText(line)).toBe('abcxy');
|
||||||
|
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||||||
|
for (const span of spans.slice(0, 3)) {
|
||||||
|
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||||||
|
expect(span.style.textDecoration).toBe('');
|
||||||
|
}
|
||||||
|
for (const span of spans.slice(3)) {
|
||||||
|
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||||||
|
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(span.style.textDecoration).toBe('underline');
|
||||||
|
}
|
||||||
|
// Grid positions continue straight on from the pending text.
|
||||||
|
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||||
|
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||||
|
expect(overlay.style.display).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a wide composition by cell width after wide pending text', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('今日は');
|
||||||
|
addon.setComposition('天気');
|
||||||
|
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||||
|
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||||
|
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||||
|
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||||
|
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||||
|
const { addon } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setFlushed(2, 'zz');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.pendingText).toBe('abc');
|
||||||
|
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||||||
|
expect(addon.composition).toBe('xy');
|
||||||
|
expect(addon.state.pendingText).toBe('abc');
|
||||||
|
expect(addon.state.flushedText).toBe('zz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows on an empty prompt without making anything pending', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('かな');
|
||||||
|
expect(addon.pendingText).toBe('');
|
||||||
|
expect(addon.hasPending).toBe(false);
|
||||||
|
expect(addon.state.visible).toBe(true);
|
||||||
|
expect(compositionText(overlay)).toBe('かな');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||||||
|
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||||||
|
const { addon, overlay } = setup({ cols: 12 });
|
||||||
|
addon.appendText('abcdefgh');
|
||||||
|
addon.setComposition('WXYZ');
|
||||||
|
const lines = lineDivs(overlay);
|
||||||
|
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||||||
|
expect(compositionText(overlay)).toBe('WXYZ');
|
||||||
|
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||||
|
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||||
|
expect(second[0].style.left).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||||
|
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||||||
|
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||||||
|
addon.appendText('abcdefghij');
|
||||||
|
addon.setComposition('XYZ');
|
||||||
|
const lines = lineDivs(overlay);
|
||||||
|
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||||||
|
expect(compositionText(overlay)).toBe('XYZ');
|
||||||
|
const first = Array.from(lines[0].children);
|
||||||
|
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.setComposition('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(addon.pendingText).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.setComposition('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
expect(overlay.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.clear();
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
addon.addChar('q');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.removeChar()).toBe('pending');
|
||||||
|
expect(addon.pendingText).toBe('ab');
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('ka');
|
||||||
|
expect(compositionText(overlay)).toBe('ka');
|
||||||
|
expect(addon.removeChar()).toBe(false);
|
||||||
|
expect(addon.composition).toBe('');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(overlay.style.display).toBe('none');
|
||||||
|
expect(addon.state.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setFlushed(3, 'abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(addon.removeChar()).toBe('flushed');
|
||||||
|
expect(compositionText(overlay)).toBe('');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text appended while composing lands before the tail', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('ab');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.addChar('c');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rerender() and refreshFont() keep the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
addon.rerender();
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
addon.refreshFont();
|
||||||
|
expect(compositionText(overlay)).toBe('xy');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders when only the composition changes', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('abc');
|
||||||
|
addon.setComposition('x');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips control characters and line breaks from the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.setComposition('a\nb\u0007c
');
|
||||||
|
expect(addon.composition).toBe('abc');
|
||||||
|
expect(compositionText(overlay)).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the block cursor after the composition', () => {
|
||||||
|
const { addon, overlay } = setup();
|
||||||
|
addon.appendText('ab');
|
||||||
|
addon.setComposition('xy');
|
||||||
|
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||||
|
// prompt col 0 + offset 2 + 4 cells
|
||||||
|
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"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.",
|
"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.32.0",
|
"version": "1.34.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# 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.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -207,12 +212,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -372,10 +384,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -426,7 +438,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
|
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||||
|
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||||
|
stronger model it consults before committing to an approach, on a recurring error and
|
||||||
|
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||||
|
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||||
|
`fable` do anything).
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -129,12 +134,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -294,4 +306,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
|
|||||||
@@ -340,11 +340,18 @@ ESC=$(printf '\033')
|
|||||||
### Starting a worker
|
### Starting a worker
|
||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
|
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||||
|
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||||
|
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||||
|
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||||
|
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||||
|
`CODEMAN_WORKER_ADVISOR` is set.
|
||||||
|
|
||||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
|||||||
|
|
||||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||||
`envOverrides`). Three differences that break copied code:
|
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||||
|
differences that break copied code:
|
||||||
|
|
||||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||||
(`session-routes.ts:648`).
|
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||||
|
|
||||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||||
|
|||||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
|||||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||||
|
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||||
|
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||||
|
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||||
|
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||||
|
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||||
@@ -196,8 +201,8 @@ idle:
|
|||||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||||
Every topology in the next section is this protocol plus a wiring diagram.
|
Every topology in the next section is this protocol plus a wiring diagram.
|
||||||
|
|
||||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||||
`--name` gate above). Session create installs the hooks block into the workspace
|
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
|||||||
[§5.2](#52-readiness)).
|
[§5.2](#52-readiness)).
|
||||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||||
|
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||||
|
|||||||
@@ -83,9 +83,11 @@ appendFileSync(
|
|||||||
|
|
||||||
// 4. Minify frontend assets
|
// 4. Minify frontend assets
|
||||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||||
|
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||||
|
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
|
||||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||||
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
||||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||||
@@ -111,8 +113,10 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'notification-manager.js',
|
'notification-manager.js',
|
||||||
'keyboard-accessory.js',
|
'keyboard-accessory.js',
|
||||||
'input-cjk.js',
|
'input-cjk.js',
|
||||||
|
'mobile-ime-preview.js',
|
||||||
'terminal-keycode229-recovery.js',
|
'terminal-keycode229-recovery.js',
|
||||||
'sanitize-html.js',
|
'sanitize-html.js',
|
||||||
|
'tab-layout-browser.js',
|
||||||
'app.js',
|
'app.js',
|
||||||
'tab-rail-resize.js',
|
'tab-rail-resize.js',
|
||||||
'terminal-ui.js',
|
'terminal-ui.js',
|
||||||
@@ -146,10 +150,44 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
|
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
|
||||||
}
|
}
|
||||||
writeFileSync(join(distPublic, 'index.html'), html);
|
writeFileSync(join(distPublic, 'index.html'), html);
|
||||||
|
|
||||||
|
// Rewrite sw.js from the SAME manifest that just renamed the files.
|
||||||
|
//
|
||||||
|
// The service worker's precache list used to be maintained by hand with the
|
||||||
|
// pre-hash names, so after this step every entry in it pointed at a file that
|
||||||
|
// no longer existed and `cache.add(...).catch(() => {})` hid it. Deriving it
|
||||||
|
// here is the only way the two cannot drift.
|
||||||
|
//
|
||||||
|
// The cache key gets the build hash for the same reason: `activate` deletes
|
||||||
|
// every cache that is not the current one, so a constant key meant that
|
||||||
|
// cleanup never ran and hashed assets from every past release piled up.
|
||||||
|
const swPath = join(distPublic, 'sw.js');
|
||||||
|
let sw = readFileSync(swPath, 'utf8');
|
||||||
|
const hashedAssets = Object.values(manifest);
|
||||||
|
const buildId = createHash('md5').update(hashedAssets.join('|')).digest('hex').slice(0, 12);
|
||||||
|
// Rewrite the two declarations. Anchored on the full `const … = …;` text so
|
||||||
|
// each pattern occurs exactly once and cannot collide with prose in sw.js's
|
||||||
|
// own comments — an earlier cut used bare `__BUILD_ID__` sentinels and the
|
||||||
|
// first match landed in the comment that documented them, leaving the real
|
||||||
|
// constant untouched and still producing a plausible-looking cache key.
|
||||||
|
const swEdits = [
|
||||||
|
["const BUILD_ID = 'dev';", `const BUILD_ID = '${buildId}';`],
|
||||||
|
['const HASHED_ASSETS = [];', `const HASHED_ASSETS = [${hashedAssets.map((p) => JSON.stringify(p)).join(', ')}];`],
|
||||||
|
];
|
||||||
|
for (const [from, to] of swEdits) {
|
||||||
|
const hits = sw.split(from).length - 1;
|
||||||
|
if (hits !== 1) {
|
||||||
|
throw new Error(`sw.js: expected exactly one \`${from}\`, found ${hits} — precache would ship stale`);
|
||||||
|
}
|
||||||
|
sw = sw.replace(from, to);
|
||||||
|
}
|
||||||
|
writeFileSync(swPath, sw);
|
||||||
|
|
||||||
console.log(' Hashed files:');
|
console.log(' Hashed files:');
|
||||||
for (const [orig, hashed] of Object.entries(manifest)) {
|
for (const [orig, hashed] of Object.entries(manifest)) {
|
||||||
console.log(` ${orig} -> ${hashed}`);
|
console.log(` ${orig} -> ${hashed}`);
|
||||||
}
|
}
|
||||||
|
console.log(` sw.js: cache bucket codeman-${buildId}, ${hashedAssets.length} precached assets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Compress with gzip + brotli
|
// 6. Compress with gzip + brotli
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Browser-test exclusion check.
|
||||||
|
*
|
||||||
|
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
|
||||||
|
* clean checkout) have no chromium, so such a file dies with
|
||||||
|
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
|
||||||
|
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
|
||||||
|
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
|
||||||
|
* BY HAND, and a new browser test simply does not appear in it unless someone
|
||||||
|
* remembers. The omission is invisible on a developer machine that has run
|
||||||
|
* `npx playwright install`, where the test passes, and only shows up on a clean
|
||||||
|
* runner.
|
||||||
|
*
|
||||||
|
* Two deliberate design choices:
|
||||||
|
*
|
||||||
|
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
|
||||||
|
* would miss the browser tests that predate that convention
|
||||||
|
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
|
||||||
|
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
|
||||||
|
* file dangerous is importing a browser driver, so that is what is tested.
|
||||||
|
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
|
||||||
|
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
|
||||||
|
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
|
||||||
|
*
|
||||||
|
* 2. **The exclusion side is answered by vitest itself**, via
|
||||||
|
* `vitest list --filesOnly`, rather than by re-implementing glob matching
|
||||||
|
* against the config's `exclude` array. Patterns there include `test/mobile/**`
|
||||||
|
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
|
||||||
|
* edge case would report a gap that does not exist, or miss one that does.
|
||||||
|
* Asking the real resolver cannot drift from the real behaviour.
|
||||||
|
*
|
||||||
|
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
|
||||||
|
* check itself only runs when this file is executed directly.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname, relative, sep, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
|
||||||
|
const SUITES_FILE = join('config', 'test-suites.ts');
|
||||||
|
|
||||||
|
/** Importing any one of these means the test needs a real browser binary. */
|
||||||
|
const BROWSER_DRIVER =
|
||||||
|
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
|
||||||
|
|
||||||
|
/** @param {string} source */
|
||||||
|
export function importsBrowserDriver(source) {
|
||||||
|
return BROWSER_DRIVER.test(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} dir @returns {string[]} */
|
||||||
|
function walk(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(path));
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
|
||||||
|
* `vitest list` prints).
|
||||||
|
*
|
||||||
|
* @param {string} root
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function findTestFiles(root) {
|
||||||
|
return walk(join(root, 'test'))
|
||||||
|
.map((file) => relative(root, file).split(sep).join('/'))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of {@link findTestFiles} that imports a browser driver.
|
||||||
|
*
|
||||||
|
* @param {string} root
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function findBrowserTests(root) {
|
||||||
|
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
|
||||||
|
* blank or decorative lines are ignored rather than assuming the format is pristine.
|
||||||
|
*
|
||||||
|
* @param {string} output
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function parseVitestFileList(output) {
|
||||||
|
return new Set(
|
||||||
|
output
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.endsWith('.test.ts'))
|
||||||
|
.map((line) => line.replace(/^\.\//, ''))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the `vitest list` paths and the walked tree name at least one file in common.
|
||||||
|
* False means the two sides are not speaking the same path format (absolute paths, backslashes
|
||||||
|
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
|
||||||
|
* against a perfectly non-empty listing.
|
||||||
|
*
|
||||||
|
* @param {Set<string>} ciFiles
|
||||||
|
* @param {string[]} testFiles
|
||||||
|
*/
|
||||||
|
export function listingMatchesTree(ciFiles, testFiles) {
|
||||||
|
return testFiles.some((file) => ciFiles.has(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} browserTests
|
||||||
|
* @param {Set<string>} ciFiles
|
||||||
|
* @returns {string[]} browser-driven files that the CI config would still collect
|
||||||
|
*/
|
||||||
|
export function findLeaks(browserTests, ciFiles) {
|
||||||
|
return browserTests.filter((file) => ciFiles.has(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const testFiles = findTestFiles(ROOT);
|
||||||
|
const browserTests = findBrowserTests(ROOT);
|
||||||
|
|
||||||
|
let collected;
|
||||||
|
try {
|
||||||
|
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('✗ could not enumerate the CI test set via `vitest list`.');
|
||||||
|
console.error(err.stderr ? err.stderr.toString() : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciFiles = parseVitestFileList(collected);
|
||||||
|
if (ciFiles.size === 0) {
|
||||||
|
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
|
||||||
|
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
|
||||||
|
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
|
||||||
|
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
|
||||||
|
if (!listingMatchesTree(ciFiles, testFiles)) {
|
||||||
|
const sample = [...ciFiles].slice(0, 3).join(', ');
|
||||||
|
console.error(
|
||||||
|
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaked = findLeaks(browserTests, ciFiles);
|
||||||
|
|
||||||
|
if (leaked.length > 0) {
|
||||||
|
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
|
||||||
|
for (const file of leaked) console.error(` ${file}`);
|
||||||
|
console.error(`
|
||||||
|
These import a browser driver, so on a runner with no chromium they fail with
|
||||||
|
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
|
||||||
|
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
|
||||||
|
it, and \`npm run test:browser\` its includes).
|
||||||
|
|
||||||
|
They may well pass on this machine; that is the trap. To reproduce a clean
|
||||||
|
runner locally:
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
|
||||||
|
* pinned by test/git-hooks.test.ts.
|
||||||
|
*
|
||||||
|
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
|
||||||
|
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
|
||||||
|
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
|
||||||
|
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
|
||||||
|
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
|
||||||
|
*
|
||||||
|
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
|
||||||
|
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
|
||||||
|
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
|
||||||
|
* within a day. The checks below mirror the static CI job.
|
||||||
|
*
|
||||||
|
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
|
||||||
|
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
|
||||||
|
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
|
||||||
|
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
|
||||||
|
* the second case is usually another session's WIP, which must not block this push.
|
||||||
|
*
|
||||||
|
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
|
||||||
|
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
|
||||||
|
* survive `npm install`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
|
||||||
|
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
|
||||||
|
* A changed body still reaches installed hooks, because the refresh compares the whole file.
|
||||||
|
*/
|
||||||
|
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
|
||||||
|
* is the argument list for `npm run`, and each is a step of the static job in
|
||||||
|
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
|
||||||
|
*/
|
||||||
|
export const PRE_PUSH_CHECKS = [
|
||||||
|
['check:lockfile'],
|
||||||
|
['generate:cli-catalog', '--', '--check'],
|
||||||
|
['check:browser-excludes'],
|
||||||
|
['check:frontend-syntax'],
|
||||||
|
['format:check'],
|
||||||
|
['lint'],
|
||||||
|
['typecheck'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
|
||||||
|
* Derived from what each check reads: src/ (format:check, lint, typecheck,
|
||||||
|
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
|
||||||
|
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
|
||||||
|
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
|
||||||
|
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
|
||||||
|
* --check diffs its generated block), tsconfig.json (typecheck, and
|
||||||
|
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
|
||||||
|
* (format:check; the Prettier CLI honours .editorconfig by default).
|
||||||
|
*/
|
||||||
|
export const PRE_PUSH_WATCHED_PATHS = [
|
||||||
|
'src',
|
||||||
|
'config',
|
||||||
|
'scripts',
|
||||||
|
'test',
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
'install.sh',
|
||||||
|
'tsconfig.json',
|
||||||
|
'.prettierignore',
|
||||||
|
'.editorconfig',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the pre-push hook script.
|
||||||
|
*
|
||||||
|
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
|
||||||
|
*/
|
||||||
|
export function renderPrePushHook() {
|
||||||
|
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
|
||||||
|
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
|
||||||
|
|
||||||
|
return `#!/bin/sh
|
||||||
|
${PRE_PUSH_MARKER}
|
||||||
|
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
|
||||||
|
# it is regenerated on npm install. Delete the marker line above to take ownership
|
||||||
|
# and the installer will leave your version alone.
|
||||||
|
#
|
||||||
|
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
|
||||||
|
# Skip always: remove this file.
|
||||||
|
|
||||||
|
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
|
||||||
|
|
||||||
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||||
|
cd "$repo_root" || exit 0
|
||||||
|
|
||||||
|
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
|
||||||
|
# npm install). Warn rather than blocking the push on a setup detail.
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
|
||||||
|
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
|
||||||
|
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
|
||||||
|
|
||||||
|
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
|
||||||
|
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
|
||||||
|
# The checks below read the working tree, so they only say something about a pushed commit
|
||||||
|
# that IS the checked-out HEAD (tags are peeled to their commit first).
|
||||||
|
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
|
||||||
|
has_content=0
|
||||||
|
not_head=''
|
||||||
|
while read -r localref localsha _remoteref _remotesha; do
|
||||||
|
[ -z "$localsha" ] && continue
|
||||||
|
case "$localsha" in
|
||||||
|
0000000000000000000000000000000000000000) ;;
|
||||||
|
*)
|
||||||
|
has_content=1
|
||||||
|
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
|
||||||
|
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$has_content" = "0" ] && exit 0
|
||||||
|
|
||||||
|
if [ -n "$not_head" ]; then
|
||||||
|
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
|
||||||
|
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
|
||||||
|
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
|
||||||
|
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
|
||||||
|
trap 'rm -f "$log"' EXIT
|
||||||
|
|
||||||
|
failed=''
|
||||||
|
run_check() {
|
||||||
|
if ! npm run --silent "$@" >"$log" 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "pre-push: FAILED npm run $*"
|
||||||
|
tail -n 25 "$log"
|
||||||
|
failed="$failed $1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "pre-push: running static checks (~10-40s)..."
|
||||||
|
${runs}
|
||||||
|
|
||||||
|
if [ -n "$failed" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "pre-push: blocked by:$failed"
|
||||||
|
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-push: static checks passed."
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what to do with an existing hook file.
|
||||||
|
*
|
||||||
|
* @param {{ existing: string | null | undefined, next: string }} args
|
||||||
|
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||||
|
*/
|
||||||
|
export function planHookInstall({ existing, next }) {
|
||||||
|
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
|
||||||
|
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
|
||||||
|
return existing === next ? 'up-to-date' : 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} cwd @param {string[]} args */
|
||||||
|
function git(cwd, args) {
|
||||||
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
|
||||||
|
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
|
||||||
|
*
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
|
function canonicalPath(path) {
|
||||||
|
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
|
||||||
|
* is nothing to install into.
|
||||||
|
*
|
||||||
|
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
|
||||||
|
* `.git` is a FILE pointing at the parent repo, so the hooks live under
|
||||||
|
* `--git-common-dir`.
|
||||||
|
*
|
||||||
|
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
|
||||||
|
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
|
||||||
|
* shared hooks directory used by every repo on the machine); installing there would
|
||||||
|
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
|
||||||
|
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
|
||||||
|
* the comparison is on canonical paths rather than on whether the setting exists.
|
||||||
|
*
|
||||||
|
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
|
||||||
|
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
|
||||||
|
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function resolveGitHooksDir(repoRoot) {
|
||||||
|
try {
|
||||||
|
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
|
||||||
|
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
|
||||||
|
// Both are printed relative to the cwd (repoRoot) unless already absolute.
|
||||||
|
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
|
||||||
|
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
|
||||||
|
if (!hooks || !common) return null;
|
||||||
|
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
|
||||||
|
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
|
||||||
|
* {@link planHookInstall}: a hook without the marker is never touched.
|
||||||
|
*
|
||||||
|
* @param {string} hooksDir
|
||||||
|
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||||
|
*/
|
||||||
|
export function installPrePushHook(hooksDir) {
|
||||||
|
const path = join(hooksDir, 'pre-push');
|
||||||
|
const next = renderPrePushHook();
|
||||||
|
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||||
|
const action = planHookInstall({ existing, next });
|
||||||
|
if (action === 'write') {
|
||||||
|
mkdirSync(hooksDir, { recursive: true });
|
||||||
|
writeFileSync(path, next, { mode: 0o755 });
|
||||||
|
chmodSync(path, 0o755); // `mode` only applies when the file is created
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
@@ -55,9 +55,61 @@ export function agentImageNpmPackages(catalog) {
|
|||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `--build-arg` pairs the agent image takes. PURE. */
|
/**
|
||||||
export function agentImageBuildArgPairs(catalog) {
|
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
|
||||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]];
|
* ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
||||||
|
*/
|
||||||
|
export const GIT_HOST_CLI_BUILD_ARGS = [
|
||||||
|
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
|
||||||
|
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
||||||
|
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
||||||
|
*/
|
||||||
|
export const GIT_IDENTITY_BUILD_ARGS = [
|
||||||
|
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||||
|
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||||
|
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||||
|
* as before these existed; anything other than 0/1 is refused rather than guessed at.
|
||||||
|
*/
|
||||||
|
export function gitHostCliBuildArgPairs(env) {
|
||||||
|
const pairs = [];
|
||||||
|
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
|
||||||
|
const value = env[envName];
|
||||||
|
if (value === undefined || value === '') continue;
|
||||||
|
if (value !== '0' && value !== '1') {
|
||||||
|
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
pairs.push([argName, value]);
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
|
||||||
|
export function gitIdentityBuildArgPairs(env) {
|
||||||
|
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
|
||||||
|
const configured = pairs.filter(([, value]) => value !== '');
|
||||||
|
if (configured.length === 0) return [];
|
||||||
|
if (configured.length !== pairs.length) {
|
||||||
|
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
||||||
|
throw new Error(`${names} must both be set when configuring Git identity`);
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||||
|
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
||||||
|
return [
|
||||||
|
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
|
||||||
|
...gitHostCliBuildArgPairs(env),
|
||||||
|
...gitIdentityBuildArgPairs(env),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the committed catalogue. IO. */
|
/** Read the committed catalogue. IO. */
|
||||||
|
|||||||
+16
-4
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 5. Install git pre-commit hook (format check)
|
// 5. Install git hooks (pre-commit format check, pre-push static checks)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
if (!isGlobalInstall) {
|
if (!isGlobalInstall) {
|
||||||
try {
|
try {
|
||||||
const { writeFileSync, mkdirSync } = await import('fs');
|
const { writeFileSync, mkdirSync } = await import('fs');
|
||||||
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
|
||||||
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
|
||||||
|
// null when this directory is not the top of a git checkout.
|
||||||
|
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
|
||||||
|
if (gitHooksDir) {
|
||||||
mkdirSync(gitHooksDir, { recursive: true });
|
mkdirSync(gitHooksDir, { recursive: true });
|
||||||
const hook = `#!/bin/bash
|
const hook = `#!/bin/bash
|
||||||
# Auto-installed by postinstall — prevents CI format failures
|
# Auto-installed by postinstall — prevents CI format failures
|
||||||
@@ -379,9 +382,18 @@ fi
|
|||||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||||
|
|
||||||
|
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
|
||||||
|
// hook the developer wrote themselves is left alone.
|
||||||
|
const action = installPrePushHook(gitHooksDir);
|
||||||
|
if (action === 'write') {
|
||||||
|
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
|
||||||
|
} else if (action === 'skip-foreign') {
|
||||||
|
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — git hook is a convenience
|
// Non-critical — git hooks are a convenience
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -74,6 +74,15 @@ echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
|
|||||||
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
|
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
|
||||||
export GIT_TERMINAL_PROMPT=0
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
|
||||||
|
# --node is the server's process.execPath, a VERSIONED path (Homebrew resolves it
|
||||||
|
# into Cellar/node/<ver>/). A `brew upgrade node` under a long-running server
|
||||||
|
# deletes it, and every status write then failed, so the status stayed "queued"
|
||||||
|
# forever. Fall back to whatever node is on PATH.
|
||||||
|
if [ ! -x "$NODE" ]; then
|
||||||
|
echo "[self-update] WARN: $NODE is not executable, falling back to node on PATH"
|
||||||
|
NODE="$(command -v node || echo node)"
|
||||||
|
fi
|
||||||
|
|
||||||
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
|
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
|
||||||
STASH_REF=""
|
STASH_REF=""
|
||||||
MANUAL_CMD=""
|
MANUAL_CMD=""
|
||||||
@@ -276,7 +285,17 @@ case "$SUPERVISOR" in
|
|||||||
# domain needs root, but we don't need it — kill the server and launchd
|
# domain needs root, but we don't need it — kill the server and launchd
|
||||||
# respawns it on the new dist/ within ThrottleInterval seconds.
|
# respawns it on the new dist/ within ThrottleInterval seconds.
|
||||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||||
: # respawn is launchd's job from here
|
# Respawn is launchd's job, but only once the old process EXITS. A graceful
|
||||||
|
# shutdown that hangs leaves the port closed and the service down, so
|
||||||
|
# escalate to SIGKILL (tmux sessions live outside the server and survive).
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
kill -0 "$SERVER_PID" 2>/dev/null || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||||
|
echo "[self-update] server pid $SERVER_PID still alive 30s after SIGTERM, sending SIGKILL"
|
||||||
|
kill -9 "$SERVER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
||||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||||
|
|||||||
+26
-8
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# 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.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -207,12 +212,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -372,10 +384,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -426,7 +438,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
|
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||||
|
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||||
|
stronger model it consults before committing to an approach, on a recurring error and
|
||||||
|
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||||
|
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||||
|
`fable` do anything).
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
@@ -129,12 +134,19 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
|
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||||
|
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||||
|
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||||
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||||
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||||
@@ -294,4 +306,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.30.1
|
CODEMAN_PREAMBLE=1.33.4
|
||||||
|
|||||||
@@ -340,11 +340,18 @@ ESC=$(printf '\033')
|
|||||||
### Starting a worker
|
### Starting a worker
|
||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
|
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||||
|
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||||
|
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||||
|
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||||
|
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||||
|
`CODEMAN_WORKER_ADVISOR` is set.
|
||||||
|
|
||||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
|||||||
|
|
||||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||||
`envOverrides`). Three differences that break copied code:
|
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||||
|
differences that break copied code:
|
||||||
|
|
||||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||||
(`session-routes.ts:648`).
|
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||||
|
|
||||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||||
|
|||||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
|||||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||||
|
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||||
|
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||||
|
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||||
|
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||||
|
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||||
@@ -196,8 +201,8 @@ idle:
|
|||||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||||
Every topology in the next section is this protocol plus a wiring diagram.
|
Every topology in the next section is this protocol plus a wiring diagram.
|
||||||
|
|
||||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||||
`--name` gate above). Session create installs the hooks block into the workspace
|
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
|||||||
[§5.2](#52-readiness)).
|
[§5.2](#52-readiness)).
|
||||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||||
|
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Carry a Codeman rename into Claude Code's own session title.
|
||||||
|
*
|
||||||
|
* Claude Code keeps a conversation's title in its transcript as a
|
||||||
|
* `{"type":"custom-title"}` row (what `/rename` writes), last row wins, and the
|
||||||
|
* `/resume` picker shows `customTitle ?? aiTitle`. Renaming a tab in Codeman
|
||||||
|
* used to change only the tab, so `/resume` kept listing the old name.
|
||||||
|
*
|
||||||
|
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
|
||||||
|
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
|
||||||
|
* process holds no title of its own and never writes one back. A process that
|
||||||
|
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
|
||||||
|
* there the new title holds from the next spawn, which pins the new name.
|
||||||
|
*
|
||||||
|
* @module claude-session-title
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a `custom-title` row for `conversationId` to an existing transcript.
|
||||||
|
* Never creates the file: a missing transcript means the conversation has not
|
||||||
|
* been written yet, and a file of only a title row would show up in `/resume`
|
||||||
|
* as an empty conversation. Returns whether a row was written.
|
||||||
|
*/
|
||||||
|
export async function appendClaudeCustomTitle(
|
||||||
|
transcriptPath: string,
|
||||||
|
conversationId: string,
|
||||||
|
title: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const customTitle = title.trim();
|
||||||
|
// Claude reads the row through `customTitle ?? aiTitle`, so an empty string
|
||||||
|
// would blank the picker entry rather than fall back to the generated title.
|
||||||
|
if (!customTitle) return false;
|
||||||
|
try {
|
||||||
|
if (!(await fs.stat(transcriptPath)).isFile()) return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// One O_APPEND write of one line, the same way Claude appends its own rows,
|
||||||
|
// so it cannot interleave with a row the live process is writing.
|
||||||
|
const row = JSON.stringify({ type: 'custom-title', customTitle, sessionId: conversationId });
|
||||||
|
await fs.appendFile(transcriptPath, `${row}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+10
@@ -129,6 +129,8 @@ program
|
|||||||
|
|
||||||
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
||||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||||
|
/** Graceful shutdown budget before the process force-exits (see the SIGTERM handler). */
|
||||||
|
const SHUTDOWN_FORCE_EXIT_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
||||||
@@ -1002,6 +1004,14 @@ webCmd.action(async (options) => {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
||||||
|
// A hung stop() must not keep the process alive: the listener is already
|
||||||
|
// closed by then, and a KeepAlive LaunchDaemon only respawns the server once
|
||||||
|
// it EXITS (systemd would SIGKILL after TimeoutStopSec; launchd does not).
|
||||||
|
// Seen after a self-update on macOS: port closed, process alive, service down.
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error(palette.err(`Shutdown did not finish in ${SHUTDOWN_FORCE_EXIT_MS / 1000}s, forcing exit`));
|
||||||
|
process.exit(1);
|
||||||
|
}, SHUTDOWN_FORCE_EXIT_MS).unref();
|
||||||
try {
|
try {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Write side of the CLI registry (docs/cli-enable-disable-plan.md, Phases 3/5).
|
||||||
|
*
|
||||||
|
* Kept deliberately SEPARATE from `registry.ts`, whose reading path does no writes on import
|
||||||
|
* (`schemas.ts` imports it, transitively). Only `cli-registry-routes.ts` imports this module,
|
||||||
|
* so that property still holds for every OTHER importer of the registry.
|
||||||
|
*
|
||||||
|
* Every mutation goes through `mutateRegistryFile()`, which does three things the #476 review
|
||||||
|
* found missing:
|
||||||
|
*
|
||||||
|
* - **Serialized.** Mutations run one at a time on a single promise chain, and each one
|
||||||
|
* reads, changes, writes and reloads before the next starts. Unserialized read-modify-write
|
||||||
|
* lost toggles when three `PUT /api/clis/:id` calls ran in parallel.
|
||||||
|
* - **Refuses a file it must not trust.** The reader ignores a `clis.json` with any
|
||||||
|
* group/world permission bit and quarantines one that does not parse. The writer used to
|
||||||
|
* treat both as "start fresh", so one Settings click replaced a hand-edited file with a
|
||||||
|
* one-key file, or rewrote a refused file as 0600 and so trusted it. It now starts fresh
|
||||||
|
* ONLY on ENOENT and otherwise throws `RegistryWriteRefusedError`, leaving the file alone.
|
||||||
|
* - **Unique temp file.** Every write gets its own tmp name before the rename, so two writes
|
||||||
|
* can never rename each other's temp file away (the ENOENT-on-rename 500s).
|
||||||
|
*
|
||||||
|
* Same tmp+rename+0600 shape as `custom-model-hosts.ts`. The file is hand-editable, so a
|
||||||
|
* write must never leave it half-written, and 0600 is the mode `isUnsafePermissions()`
|
||||||
|
* requires on the next read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { isUnsafePermissions, registryFilePath, reloadCliRegistry } from './registry.js';
|
||||||
|
import type { CliRegistryFile } from './types.js';
|
||||||
|
|
||||||
|
/** A write refused because the existing `clis.json` must not be overwritten. The message is user-facing. */
|
||||||
|
export class RegistryWriteRefusedError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'RegistryWriteRefusedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the raw override file for mutation. Only a MISSING file starts fresh. A file with
|
||||||
|
* unsafe permissions, one that cannot be read, or one that does not parse is refused rather
|
||||||
|
* than overwritten, because the user's hand-edit is worth more than one toggle.
|
||||||
|
*/
|
||||||
|
export async function readRegistryFileForWrite(): Promise<CliRegistryFile> {
|
||||||
|
const path = registryFilePath();
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(path, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, clis: {} };
|
||||||
|
throw new RegistryWriteRefusedError(`Cannot read ${path} (${(err as Error).message}); not changing it.`);
|
||||||
|
}
|
||||||
|
if (isUnsafePermissions(path)) {
|
||||||
|
throw new RegistryWriteRefusedError(
|
||||||
|
`${path} has group/world permission bits, so Codeman ignores it. Run \`chmod 600 ${path}\` and check its contents before changing CLIs here.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw new RegistryWriteRefusedError(
|
||||||
|
`${path} is not valid JSON (${(err as Error).message}). Fix or remove it before changing CLIs here.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clis = (parsed as { clis?: unknown } | null)?.clis;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || typeof clis !== 'object' || clis === null) {
|
||||||
|
throw new RegistryWriteRefusedError(`${path} has no "clis" object. Fix or remove it before changing CLIs here.`);
|
||||||
|
}
|
||||||
|
return parsed as CliRegistryFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeRegistryFile(file: CliRegistryFile): Promise<void> {
|
||||||
|
const target = registryFilePath();
|
||||||
|
const dir = dirname(target);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
|
||||||
|
await fs.rename(tmp, target);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one registry mutation. The chain holds exactly one at a time: `fn` receives the
|
||||||
|
* current file and returns `{ file, result }`. If `file` is set it is written and the
|
||||||
|
* registry reloaded before the next mutation starts; if not, nothing is written, which is
|
||||||
|
* how a validation failure returns early. Checks made inside `fn` (does this id exist,
|
||||||
|
* is it a duplicate) therefore see every earlier mutation's result.
|
||||||
|
*
|
||||||
|
* A failed mutation rejects its own caller only. The chain keeps going.
|
||||||
|
*/
|
||||||
|
export function mutateRegistryFile<T>(
|
||||||
|
fn: (file: CliRegistryFile) => Promise<{ file?: CliRegistryFile; result: T }> | { file?: CliRegistryFile; result: T }
|
||||||
|
): Promise<T> {
|
||||||
|
const run = mutationChain.then(async () => {
|
||||||
|
const current = await readRegistryFileForWrite();
|
||||||
|
const { file, result } = await fn(current);
|
||||||
|
if (file) {
|
||||||
|
await writeRegistryFile(file);
|
||||||
|
reloadCliRegistry();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
mutationChain = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
@@ -48,6 +48,16 @@ function filePath(): string {
|
|||||||
return dataPath('clis.json');
|
return dataPath('clis.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved path of `~/.codeman/clis.json`, exported for the write API
|
||||||
|
* (`cli-registry-writer.ts`, docs/cli-enable-disable-plan.md Phases 3/5) so both the read and
|
||||||
|
* write sides resolve the SAME path through the SAME instance-scoped helper — never a second
|
||||||
|
* `dataPath('clis.json')` call that could drift from this one under a future `dataPath()` change.
|
||||||
|
*/
|
||||||
|
export function registryFilePath(): string {
|
||||||
|
return filePath();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys that must never be merged out of a hand-editable JSON file.
|
* Keys that must never be merged out of a hand-editable JSON file.
|
||||||
*
|
*
|
||||||
@@ -90,8 +100,11 @@ export interface LoadResult {
|
|||||||
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
|
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
|
||||||
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
|
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
|
||||||
* this check cannot see and does not attempt to.
|
* this check cannot see and does not attempt to.
|
||||||
|
*
|
||||||
|
* Exported for `registry-writer.ts`, which must refuse the same files: rewriting a refused
|
||||||
|
* file as 0600 would silently turn it into trusted config.
|
||||||
*/
|
*/
|
||||||
function isUnsafePermissions(path: string): boolean {
|
export function isUnsafePermissions(path: string): boolean {
|
||||||
if (process.platform === 'win32') return false;
|
if (process.platform === 'win32') return false;
|
||||||
try {
|
try {
|
||||||
const mode = statSync(path).mode & 0o777;
|
const mode = statSync(path).mode & 0o777;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||||
|
import type { McpConfigFormat } from './types.js';
|
||||||
|
|
||||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||||
const cliId = z
|
const cliId = z
|
||||||
@@ -27,6 +28,14 @@ const envName = z
|
|||||||
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
||||||
.max(64);
|
.max(64);
|
||||||
|
|
||||||
|
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
|
||||||
|
const mcpRelativePath = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||||
|
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
||||||
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
||||||
@@ -294,6 +303,23 @@ const capabilitiesSchema = z
|
|||||||
effort: z.boolean(),
|
effort: z.boolean(),
|
||||||
agentSkillInjection: z.boolean(),
|
agentSkillInjection: z.boolean(),
|
||||||
statusLineTelemetry: z.boolean(),
|
statusLineTelemetry: z.boolean(),
|
||||||
|
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||||
|
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||||
|
// never removes more than this, nor more than every selected line shares.
|
||||||
|
//
|
||||||
|
// ⚠ DECLARED, not measured off the pane, and two measured attempts are why.
|
||||||
|
// Asking whether the pane painted spaces across the unused part of each row
|
||||||
|
// separates a TUI from a shell perfectly where it fires and never
|
||||||
|
// over-stripped, but it is a function of pane WIDTH: that padding exists
|
||||||
|
// only while a rendered line stops short of the CLI's own layout width, and
|
||||||
|
// Claude Code's prose wraps to fill it — the share of padded rows on one
|
||||||
|
// live transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
|
||||||
|
// columns, so the strip did nothing at any ordinary size. Taking the
|
||||||
|
// narrowest indent on screen instead fires everywhere and over-strips, since
|
||||||
|
// a file listing inside the transcript can be the narrowest thing on it.
|
||||||
|
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||||
|
// transcript layout nobody has measured is never touched.
|
||||||
|
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
||||||
workDetect: z
|
workDetect: z
|
||||||
.object({
|
.object({
|
||||||
promptGlyph: z.string().min(1).max(8),
|
promptGlyph: z.string().min(1).max(8),
|
||||||
@@ -308,8 +334,38 @@ const capabilitiesSchema = z
|
|||||||
(src) => compileVersionRegex(src) !== null,
|
(src) => compileVersionRegex(src) !== null,
|
||||||
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||||
),
|
),
|
||||||
|
// Same guard, same reasons: this one runs over the foot of a pane capture every
|
||||||
|
// time a session settles, and ~/.codeman/clis.json can set it.
|
||||||
|
watchingLine: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(src) => compileVersionRegex(src) !== null,
|
||||||
|
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||||
|
// and every row it adds is one more row the agent itself may be able to write.
|
||||||
|
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||||
|
// Same guard again: tested against a pane row every time a session settles.
|
||||||
|
awaitingLine: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(src) => compileVersionRegex(src) !== null,
|
||||||
|
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||||
|
// time for the same reason `privilegedParams[].param` is checked against the params
|
||||||
|
// the entry declares: the failure is otherwise silent and looks like a feature that
|
||||||
|
// simply never fires.
|
||||||
|
.refine(
|
||||||
|
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
|
||||||
|
'watchingLines has nothing to bound without a watchingLine'
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
model: z
|
model: z
|
||||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||||
@@ -330,6 +386,26 @@ const capabilitiesSchema = z
|
|||||||
privilegedEnvKeys: z.array(envName).max(8),
|
privilegedEnvKeys: z.array(envName).max(8),
|
||||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||||
maxFrameBytes: z.number().int().positive().optional(),
|
maxFrameBytes: z.number().int().positive().optional(),
|
||||||
|
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||||
|
mcpConfig: z
|
||||||
|
.object({
|
||||||
|
// Home-relative, no traversal: sync writes to this path.
|
||||||
|
path: mcpRelativePath,
|
||||||
|
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||||
|
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||||
|
format: z.enum([
|
||||||
|
'claude-json',
|
||||||
|
'gemini-json',
|
||||||
|
'codex-toml',
|
||||||
|
'opencode-json',
|
||||||
|
'antigravity-json',
|
||||||
|
] as const satisfies readonly McpConfigFormat[]),
|
||||||
|
// The env var the CLI reads to move the file, and the path under it (same no-traversal
|
||||||
|
// rule: sync writes there too). Resolved from the server env at call time, never here.
|
||||||
|
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
customModelInjection: z.discriminatedUnion('kind', [
|
customModelInjection: z.discriminatedUnion('kind', [
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CliEntry } from './types.js';
|
import type { CliEntry } from './types.js';
|
||||||
|
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||||
|
|
||||||
const HOME_DIRS = {
|
const HOME_DIRS = {
|
||||||
local: '~/.local/bin',
|
local: '~/.local/bin',
|
||||||
@@ -75,11 +76,24 @@ function agentDefaults(): Pick<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `accent` on every entry below (except SHELL, which the frontend renders no
|
||||||
|
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
|
||||||
|
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
|
||||||
|
// representative hex each entry's own multi-stop gradient resolves around.
|
||||||
|
// Corrected 2026-09-21 after PR #458's review found several were simply wrong
|
||||||
|
// (e.g. claude was registered as Anthropic's brand orange, `#d97757`, but the
|
||||||
|
// button renders blue): `docs/cli-registry.md`'s own "transcribed, not
|
||||||
|
// authoritative, re-measure before wiring one up" warning for this
|
||||||
|
// DECLARED-FOR-LATER field, taken literally. The one exception is GEMINI, whose
|
||||||
|
// run-button border (#60a5fa) is the only one that disagrees with its own tab badge
|
||||||
|
// and run-mode dot (#8ab4f8); it takes the badge colour, so every accent names the
|
||||||
|
// same hex the frontend uses as that CLI's flat identity. This is a data-accuracy fix only —
|
||||||
|
// `accent` still has no reader, so nothing rendered changes because of it.
|
||||||
const CLAUDE: CliEntry = {
|
const CLAUDE: CliEntry = {
|
||||||
id: 'claude' as CliEntry['id'],
|
id: 'claude' as CliEntry['id'],
|
||||||
label: 'Claude',
|
label: 'Claude Code',
|
||||||
shortBadge: 'CC',
|
shortBadge: 'CC',
|
||||||
accent: '#d97757',
|
accent: '#3b82f6',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -200,12 +214,48 @@ const CLAUDE: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
external: false,
|
external: false,
|
||||||
|
// Claude indents its transcript body two columns and puts its own ●/✻/❯ markers
|
||||||
|
// in them, so a copy can drop two and paste flush. Claude and codex are the only
|
||||||
|
// entries that declare this, because theirs are the only gutters that have been measured.
|
||||||
|
transcriptGutter: 2,
|
||||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||||
workDetect: {
|
workDetect: {
|
||||||
promptGlyph: '❯',
|
promptGlyph: '❯',
|
||||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||||
|
// Claude prints what it started in the background on the footer row beneath its
|
||||||
|
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
|
||||||
|
// the CLI's own words for each kind of background task, and group 1 is the one
|
||||||
|
// Codeman badges the session with. Verified against a live 2.1.278 pane on
|
||||||
|
// 2026-09-21.
|
||||||
|
// ⚠️ Two things keep an agent from writing its own label here, and both matter.
|
||||||
|
// The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`)
|
||||||
|
// holds nothing but Ink's own chrome — in particular it leaves out the status line
|
||||||
|
// directly above, whose content comes from a `statusLine` command a bypassed
|
||||||
|
// session can write into its own `.claude/settings.json`. And the leading `·` keeps
|
||||||
|
// the match on the footer's own item list rather than on any text that happens to
|
||||||
|
// carry a count. A footer that ever drew the chip as its only item would report no
|
||||||
|
// watching rather than open that door. See `watchingLabel()` in
|
||||||
|
// `session-activity.ts`.
|
||||||
|
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
|
||||||
|
// has published a page and hears nothing until somebody comments on it, so the
|
||||||
|
// lookahead refuses the whole row while that chip is on it, whatever else is
|
||||||
|
// running beside it. The `^` is what makes the lookahead judge the row once:
|
||||||
|
// without it the engine retries from each later position, and a start past the
|
||||||
|
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
|
||||||
|
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
|
||||||
|
// no other chip on this row says "Artifact". Counting the chip as watching kept the
|
||||||
|
// idle alert quiet for a session that was waiting for a human.
|
||||||
|
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
|
||||||
|
// When a turn ends while background agents or an ultracode workflow are still
|
||||||
|
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
|
||||||
|
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
|
||||||
|
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
|
||||||
|
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
|
||||||
|
// and never redrawn, which is why only the newest row above the composer counts.
|
||||||
|
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
|
||||||
|
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
|
||||||
},
|
},
|
||||||
requiresMux: false,
|
requiresMux: false,
|
||||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||||
@@ -214,6 +264,8 @@ const CLAUDE: CliEntry = {
|
|||||||
transcript: 'claude-jsonl',
|
transcript: 'claude-jsonl',
|
||||||
altScreen: 'strip-full',
|
altScreen: 'strip-full',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
|
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
|
||||||
|
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
|
||||||
|
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
|
||||||
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
||||||
keyboardAccessory: 'agent',
|
keyboardAccessory: 'agent',
|
||||||
privilegedCommandGate: false,
|
privilegedCommandGate: false,
|
||||||
@@ -255,6 +307,12 @@ const CLAUDE: CliEntry = {
|
|||||||
'CLAUDE_CONFIG_DIR',
|
'CLAUDE_CONFIG_DIR',
|
||||||
],
|
],
|
||||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||||
|
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.claude.json',
|
||||||
|
format: 'claude-json',
|
||||||
|
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||||
|
},
|
||||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||||
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
||||||
// respawn, never a live hot-swap.
|
// respawn, never a live hot-swap.
|
||||||
@@ -368,7 +426,7 @@ const OPENCODE: CliEntry = {
|
|||||||
id: 'opencode' as CliEntry['id'],
|
id: 'opencode' as CliEntry['id'],
|
||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
shortBadge: 'OC',
|
shortBadge: 'OC',
|
||||||
accent: '#f59e0b',
|
accent: '#10b981',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 10,
|
order: 10,
|
||||||
@@ -435,6 +493,12 @@ const OPENCODE: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||||
|
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.config/opencode/opencode.json',
|
||||||
|
format: 'opencode-json',
|
||||||
|
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||||
|
},
|
||||||
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
||||||
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
||||||
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
||||||
@@ -454,7 +518,7 @@ const CODEX: CliEntry = {
|
|||||||
id: 'codex' as CliEntry['id'],
|
id: 'codex' as CliEntry['id'],
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
shortBadge: 'CX',
|
shortBadge: 'CX',
|
||||||
accent: '#6b7fd7',
|
accent: '#a855f7',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 20,
|
order: 20,
|
||||||
@@ -481,6 +545,7 @@ const CODEX: CliEntry = {
|
|||||||
bypassApprovals: { type: 'bool' },
|
bypassApprovals: { type: 'bool' },
|
||||||
animations: { type: 'bool' },
|
animations: { type: 'bool' },
|
||||||
model: { type: 'token', pattern: 'model' },
|
model: { type: 'token', pattern: 'model' },
|
||||||
|
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||||
resumeId: { type: 'token', pattern: 'id' },
|
resumeId: { type: 'token', pattern: 'id' },
|
||||||
},
|
},
|
||||||
variants: [
|
variants: [
|
||||||
@@ -492,6 +557,14 @@ const CODEX: CliEntry = {
|
|||||||
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
||||||
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
||||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||||
|
// One literal per level: an argv token cannot splice a value into a literal, and
|
||||||
|
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
|
||||||
|
// what admits a level, so an unknown one emits nothing.
|
||||||
|
...CODEX_REASONING_EFFORTS.map((level) => ({
|
||||||
|
flag: '--config',
|
||||||
|
value: `model_reasoning_effort=${level}`,
|
||||||
|
when: { param: 'reasoningEffort', is: level },
|
||||||
|
})),
|
||||||
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
||||||
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||||
],
|
],
|
||||||
@@ -518,7 +591,43 @@ const CODEX: CliEntry = {
|
|||||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||||
// alone separates a running turn from an idle one.
|
// alone separates a running turn from an idle one.
|
||||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
// Codex pins a row of its own while a background terminal it started is still
|
||||||
|
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
|
||||||
|
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
|
||||||
|
// bottom once the status line and the composer are counted, hence `watchingLines`.
|
||||||
|
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
|
||||||
|
// the terminal starts, follows the composer down as the conversation grows, and is
|
||||||
|
// gone after `/stop`.
|
||||||
|
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
|
||||||
|
// layout rather than its pattern. The third row from the bottom is the chip only
|
||||||
|
// while a terminal runs; with none running it is the last row of the transcript,
|
||||||
|
// which the agent writes. 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.
|
||||||
|
// ⚠️ 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
|
||||||
|
// nothing at all for it: the last rows are the composer and the status line, and
|
||||||
|
// `Sub-agents running` lives in the on-demand `/subagents` panel, not above the
|
||||||
|
// 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.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '›',
|
||||||
|
workingLine: '[Ee]sc to interrupt',
|
||||||
|
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||||
|
watchingLines: 3,
|
||||||
|
},
|
||||||
|
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||||
|
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||||
|
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||||
|
// 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one,
|
||||||
|
// never 1, so the width is not a function of the pane.
|
||||||
|
transcriptGutter: 2,
|
||||||
transcript: 'codex-rollout',
|
transcript: 'codex-rollout',
|
||||||
altScreen: 'strip-full',
|
altScreen: 'strip-full',
|
||||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||||
@@ -533,6 +642,11 @@ const CODEX: CliEntry = {
|
|||||||
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
||||||
// regression; `schema.ts` now rejects a name that is not a declared param.
|
// regression; `schema.ts` now rejects a name that is not a declared param.
|
||||||
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.codex/config.toml',
|
||||||
|
format: 'codex-toml',
|
||||||
|
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||||
|
},
|
||||||
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
||||||
// so the user's real ~/.codex/config.toml is never touched.
|
// so the user's real ~/.codex/config.toml is never touched.
|
||||||
customModelInjection: {
|
customModelInjection: {
|
||||||
@@ -565,7 +679,8 @@ const GEMINI: CliEntry = {
|
|||||||
id: 'gemini' as CliEntry['id'],
|
id: 'gemini' as CliEntry['id'],
|
||||||
label: 'Gemini',
|
label: 'Gemini',
|
||||||
shortBadge: 'GM',
|
shortBadge: 'GM',
|
||||||
accent: '#4285f4',
|
// The tab badge / run-mode-dot colour, not the run-button border (see the note above CLAUDE).
|
||||||
|
accent: '#8ab4f8',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 30,
|
order: 30,
|
||||||
@@ -633,6 +748,12 @@ const GEMINI: CliEntry = {
|
|||||||
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
||||||
// sends no geminiConfig at all would still get yolo for free.
|
// sends no geminiConfig at all would still get yolo for free.
|
||||||
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
||||||
|
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.gemini/settings.json',
|
||||||
|
format: 'gemini-json',
|
||||||
|
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||||
|
},
|
||||||
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
||||||
// start). Confirm the exact model-override env var name against the installed
|
// start). Confirm the exact model-override env var name against the installed
|
||||||
// gemini-cli version before shipping.
|
// gemini-cli version before shipping.
|
||||||
@@ -657,7 +778,7 @@ const ANTIGRAVITY: CliEntry = {
|
|||||||
id: 'antigravity' as CliEntry['id'],
|
id: 'antigravity' as CliEntry['id'],
|
||||||
label: 'Antigravity',
|
label: 'Antigravity',
|
||||||
shortBadge: 'AG',
|
shortBadge: 'AG',
|
||||||
accent: '#8b5cf6',
|
accent: '#22d3ee',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 40,
|
order: 40,
|
||||||
@@ -712,6 +833,8 @@ const ANTIGRAVITY: CliEntry = {
|
|||||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||||
// SENT config needs the flag forced off — nothing is materialized.
|
// SENT config needs the flag forced off — nothing is materialized.
|
||||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||||
|
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
|
||||||
|
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
|
||||||
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
||||||
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||||
// reasoning model. Toolbar entry stays disabled for this mode.
|
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||||
@@ -727,7 +850,7 @@ const PI: CliEntry = {
|
|||||||
id: 'pi' as CliEntry['id'],
|
id: 'pi' as CliEntry['id'],
|
||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
shortBadge: 'PI',
|
shortBadge: 'PI',
|
||||||
accent: '#10b981',
|
accent: '#f472b6',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 50,
|
order: 50,
|
||||||
@@ -851,10 +974,10 @@ const GROK: CliEntry = {
|
|||||||
shortBadge: 'GK',
|
shortBadge: 'GK',
|
||||||
// Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab
|
// Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab
|
||||||
// badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's
|
// badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's
|
||||||
// `accent` is a single hex, so this is the closest single value (the run-mode-dot colour,
|
// `accent` is a single hex, so this is the closest single value (zinc-300, the run-button
|
||||||
// zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and
|
// border and tab-badge colour). Nothing reads `accent` yet: the frontend keeps its own
|
||||||
// keeps its own hand-authored CSS; the field is here so the entry is complete.
|
// hand-authored CSS; the field is here so the entry is complete.
|
||||||
accent: '#a1a1aa',
|
accent: '#d4d4d8',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 70,
|
order: 70,
|
||||||
@@ -986,7 +1109,7 @@ const DEEPSEEK: CliEntry = {
|
|||||||
id: 'deepseek' as CliEntry['id'],
|
id: 'deepseek' as CliEntry['id'],
|
||||||
label: 'DeepSeek',
|
label: 'DeepSeek',
|
||||||
shortBadge: 'DS',
|
shortBadge: 'DS',
|
||||||
accent: '#4d6bfe',
|
accent: '#7c93ff',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 80,
|
order: 80,
|
||||||
@@ -1151,7 +1274,7 @@ const OMP: CliEntry = {
|
|||||||
id: 'omp' as CliEntry['id'],
|
id: 'omp' as CliEntry['id'],
|
||||||
label: 'OMP',
|
label: 'OMP',
|
||||||
shortBadge: 'OM',
|
shortBadge: 'OM',
|
||||||
accent: '#7c9cf5',
|
accent: '#818cf8',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stock: true,
|
stock: true,
|
||||||
order: 90,
|
order: 90,
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ export interface CliVariant {
|
|||||||
args: ArgSpec[];
|
args: ArgSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
|
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||||
|
|
||||||
|
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||||
|
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||||
|
|
||||||
export interface CliLaunch {
|
export interface CliLaunch {
|
||||||
params: Record<string, ParamSpec>;
|
params: Record<string, ParamSpec>;
|
||||||
/**
|
/**
|
||||||
@@ -344,7 +350,61 @@ export interface CliCapabilities {
|
|||||||
promptGlyph: string;
|
promptGlyph: string;
|
||||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||||
workingLine: string;
|
workingLine: string;
|
||||||
|
/**
|
||||||
|
* Source of a regex matching the row this CLI draws while work it started in the
|
||||||
|
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
|
||||||
|
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
|
||||||
|
* shows, and the whole match stands in when the pattern declares no group. A CLI that
|
||||||
|
* omits this reports no background work, which is what every CLI did before the field
|
||||||
|
* existed.
|
||||||
|
*/
|
||||||
|
watchingLine?: string;
|
||||||
|
/**
|
||||||
|
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
||||||
|
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
||||||
|
* its own above the composer, which puts it third from the bottom, so it declares
|
||||||
|
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
||||||
|
* another row an agent might be able to write, and the label is what silences an
|
||||||
|
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||||
|
*/
|
||||||
|
watchingLines?: number;
|
||||||
|
/**
|
||||||
|
* Source of a regex matching the row this CLI closes a turn with when it ended that
|
||||||
|
* turn to WAIT for workers it started and will resume on its own once they finish,
|
||||||
|
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
|
||||||
|
* as working, not idle: nothing is being asked of the user, and the next turn starts
|
||||||
|
* without them.
|
||||||
|
*
|
||||||
|
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
|
||||||
|
* once and never updates it, so the copy from an earlier turn is still on screen after
|
||||||
|
* the workers are done. Only the newest transcript row directly above the composer is
|
||||||
|
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
|
||||||
|
*/
|
||||||
|
awaitingLine?: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||||
|
* pane can drop that much and paste flush. Claude Code indents two and puts its own
|
||||||
|
* markers in those columns.
|
||||||
|
*
|
||||||
|
* ⚠ DECLARED rather than measured off the pane, and two measured attempts are why.
|
||||||
|
* Asking whether the pane painted real spaces across the unused part of each row
|
||||||
|
* separates a TUI from a shell perfectly where it fires and never over-stripped; it
|
||||||
|
* is also a function of pane WIDTH, because that padding exists only while a
|
||||||
|
* rendered line stops short of the CLI's own layout width and Claude Code's prose
|
||||||
|
* wraps to fill it. On one live transcript the share of padded rows ran 44%, 6%, 6%,
|
||||||
|
* 7% and 87% at 123, 160, 198, 235 and 298 columns, so at any ordinary window size
|
||||||
|
* the strip silently did nothing. Taking the narrowest indent on the surrounding
|
||||||
|
* rows instead fires at every width and over-strips on roughly 1% of selections,
|
||||||
|
* because a file listing inside the transcript can be the narrowest thing on screen.
|
||||||
|
*
|
||||||
|
* A declared width can do neither. The strip is the lesser of this and what every
|
||||||
|
* selected line shares, so a block can only ever shift as a unit, and it can never
|
||||||
|
* shift further than the CLI itself says its gutter is.
|
||||||
|
*
|
||||||
|
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||||
|
*/
|
||||||
|
transcriptGutter?: number;
|
||||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||||
requiresMux: boolean;
|
requiresMux: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -457,6 +517,28 @@ export interface CliCapabilities {
|
|||||||
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
||||||
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
||||||
maxFrameBytes?: number;
|
maxFrameBytes?: number;
|
||||||
|
/**
|
||||||
|
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
|
||||||
|
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
|
||||||
|
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
|
||||||
|
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
|
||||||
|
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
|
||||||
|
*/
|
||||||
|
newline?: NewlineSequence;
|
||||||
|
/**
|
||||||
|
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
|
||||||
|
* `path` is relative to the home directory. `format` names the file dialect the sync
|
||||||
|
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||||
|
* skipped by sync rather than guessed at.
|
||||||
|
*
|
||||||
|
* `relocation` names the env var the CLI itself reads to move that file (codex's
|
||||||
|
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
|
||||||
|
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
|
||||||
|
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
|
||||||
|
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||||
|
* only follows `$HOME`.
|
||||||
|
*/
|
||||||
|
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||||
/**
|
/**
|
||||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||||
@@ -629,12 +711,14 @@ export interface CliOverlays {
|
|||||||
/**
|
/**
|
||||||
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
||||||
*
|
*
|
||||||
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
* `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||||
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
||||||
* behaviour, and the frontend is deliberately untouched by the change that introduced this
|
* behaviour, and most of the frontend is deliberately untouched by the change that introduced
|
||||||
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
* this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||||
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
||||||
* being verified (a mobile/browser suite the CI gate cannot see).
|
* being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of
|
||||||
|
* this list (docs/cli-enable-disable-plan.md, Phase 2): `GET /api/clis` reads it for the
|
||||||
|
* CLI-management Settings list.
|
||||||
*
|
*
|
||||||
* They are declared now because each entry should describe its CLI completely, and because
|
* They are declared now because each entry should describe its CLI completely, and because
|
||||||
* transcribing them while the hand-written source is still on screen is when the values are
|
* transcribing them while the hand-written source is still on screen is when the values are
|
||||||
@@ -651,7 +735,13 @@ export interface CliEntry {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Two-ish character tab badge, e.g. 'OC'. */
|
/** Two-ish character tab badge, e.g. 'OC'. */
|
||||||
shortBadge: string;
|
shortBadge: string;
|
||||||
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
|
/**
|
||||||
|
* Single hex colour, measured from the CLI's actual `.btn-toolbar.btn-run.mode-<id>`
|
||||||
|
* gradient in styles.css (see stock.ts's comment above `CLAUDE` for the exact
|
||||||
|
* methodology). DECLARED-FOR-LATER (above) — no code reads this yet; styles.css's
|
||||||
|
* gradients are still hand-authored per id, not derived from this field via any
|
||||||
|
* CSS custom property. There is no `--cli-accent` variable in the codebase.
|
||||||
|
*/
|
||||||
accent: string;
|
accent: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
|
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
|||||||
* shown to the user and claude's does not follow the pattern.
|
* shown to the user and claude's does not follow the pattern.
|
||||||
*/
|
*/
|
||||||
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
|
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
|
||||||
claude: { usedBy: ['Claude Code sessions (default backend)'] },
|
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
|
||||||
|
// the registry label is the product name, "Claude Code".
|
||||||
|
claude: { label: 'Claude CLI', usedBy: ['Claude Code sessions (default backend)'] },
|
||||||
opencode: { usedBy: ['OpenCode sessions'] },
|
opencode: { usedBy: ['OpenCode sessions'] },
|
||||||
codex: { usedBy: ['Codex sessions'] },
|
codex: { usedBy: ['Codex sessions'] },
|
||||||
gemini: { usedBy: ['Gemini sessions'] },
|
gemini: { usedBy: ['Gemini sessions'] },
|
||||||
|
|||||||
@@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
|
|||||||
|
|
||||||
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
||||||
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be
|
||||||
|
* separate writes: Claude Code takes a raw `<text>\r` burst of about a hundred
|
||||||
|
* characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms
|
||||||
|
* after the text was measured to submit; this leaves room for a longer prompt.
|
||||||
|
*/
|
||||||
|
export const CRON_PASTE_ENTER_DELAY_MS = 300;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api
|
|||||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BLOCKED_TREES,
|
DEFAULT_BLOCKED_TREES,
|
||||||
isBlockedAttachmentPath,
|
isBlockedAttachmentPath,
|
||||||
@@ -100,6 +100,41 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR
|
|||||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||||
const HAS_NEWLINE = /[\r\n]/;
|
const HAS_NEWLINE = /[\r\n]/;
|
||||||
|
|
||||||
|
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||||
|
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a cron job's (single-line) prompt into its session and press Enter.
|
||||||
|
*
|
||||||
|
* `typed` goes through the mux: the text is typed, Enter is its own key, and the
|
||||||
|
* session re-presses it while the prompt is still on the composer.
|
||||||
|
*
|
||||||
|
* `paste` writes the text straight into the PTY, and must send its Enter as a
|
||||||
|
* SEPARATE write. It used to send `<text>\r` in one piece, and Claude Code (measured
|
||||||
|
* on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r`
|
||||||
|
* landed as a newline and the prompt sat unsent while the run reported
|
||||||
|
* `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake
|
||||||
|
* it, and the same composer check then covers a CLI that was not taking Enter yet.
|
||||||
|
*
|
||||||
|
* @returns false when the session had no PTY or mux to write to
|
||||||
|
*/
|
||||||
|
export async function deliverCronPrompt(
|
||||||
|
target: CronPromptTarget,
|
||||||
|
prompt: string,
|
||||||
|
inputMode: CronJob['inputMode'],
|
||||||
|
wait: (ms: number) => Promise<void> = delay
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (inputMode !== 'paste') {
|
||||||
|
return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`);
|
||||||
|
}
|
||||||
|
const text = prompt.replace(/[\r\n]+$/, '');
|
||||||
|
if (!target.write(text)) return false;
|
||||||
|
await wait(CRON_PASTE_ENTER_DELAY_MS);
|
||||||
|
if (!target.write('\r')) return false;
|
||||||
|
target.verifySubmitted(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Order-insensitive equality for the weekly-days arrays. */
|
/** Order-insensitive equality for the weekly-days arrays. */
|
||||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||||
@@ -623,15 +658,9 @@ export class CronService {
|
|||||||
const s = this.deps.sessions.get(sessionId);
|
const s = this.deps.sessions.get(sessionId);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
try {
|
try {
|
||||||
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
|
||||||
let delivered = true;
|
|
||||||
if (job.inputMode === 'paste') {
|
|
||||||
s.write(payload);
|
|
||||||
} else {
|
|
||||||
delivered = await s.writeViaMux(payload);
|
|
||||||
}
|
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
this.failRun(job, run, 'Failed to send prompt: mux write failed');
|
this.failRun(job, run, 'Failed to send prompt: the session could not be written to');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.status = 'prompt_sent';
|
run.status = 'prompt_sent';
|
||||||
|
|||||||
+105
-5
@@ -606,9 +606,65 @@ export function agentImageNpmPackages(): string[] {
|
|||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `--build-arg` pairs the agent image takes. */
|
/**
|
||||||
export function agentImageBuildArgPairs(): Array<[string, string]> {
|
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
|
||||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
|
* ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||||
|
*/
|
||||||
|
export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
|
||||||
|
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
||||||
|
* ⚠️ Mirrors `GIT_IDENTITY_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||||
|
*/
|
||||||
|
export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||||
|
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||||
|
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||||
|
* as before these existed; anything other than 0/1 is refused rather than guessed at.
|
||||||
|
*/
|
||||||
|
export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
||||||
|
const pairs: Array<[string, string]> = [];
|
||||||
|
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
|
||||||
|
const value = env[envName];
|
||||||
|
if (value === undefined || value === '') continue;
|
||||||
|
if (value !== '0' && value !== '1') {
|
||||||
|
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
pairs.push([argName, value]);
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `--build-arg` pairs for a configured Git identity. An absent pair leaves
|
||||||
|
* Git unconfigured, preserving existing deployments; a partial pair is refused.
|
||||||
|
* ⚠️ Mirrors `gitIdentityBuildArgPairs()` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||||
|
*/
|
||||||
|
export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
||||||
|
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]);
|
||||||
|
const configured = pairs.filter(([, value]) => value !== '');
|
||||||
|
if (configured.length === 0) return [];
|
||||||
|
if (configured.length !== pairs.length) {
|
||||||
|
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
||||||
|
throw new Error(`${names} must both be set when configuring Git identity`);
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||||
|
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
||||||
|
return [
|
||||||
|
['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')],
|
||||||
|
...gitHostCliBuildArgPairs(env),
|
||||||
|
...gitIdentityBuildArgPairs(env),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Credential mount resolution (IO) ==========
|
// ========== Credential mount resolution (IO) ==========
|
||||||
@@ -785,6 +841,14 @@ interface CredStorePolicy {
|
|||||||
seedFiles?: string[];
|
seedFiles?: string[];
|
||||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||||
seedWhole?: boolean;
|
seedWhole?: boolean;
|
||||||
|
/**
|
||||||
|
* Seed this store ONLY when this environment variable is exactly `1`, read when
|
||||||
|
* the container is created. For credentials that belong to an opt-in tool rather
|
||||||
|
* than to an agent CLI every case already trusts: they are not inert just because
|
||||||
|
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
|
||||||
|
* usable by anything in the container, and the agent in it is prompt-injectable).
|
||||||
|
*/
|
||||||
|
enabledByEnv?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRED_STORES: CredStorePolicy[] = [
|
const CRED_STORES: CredStorePolicy[] = [
|
||||||
@@ -829,6 +893,31 @@ const CRED_STORES: CredStorePolicy[] = [
|
|||||||
},
|
},
|
||||||
{ rel: '.config/gcloud', seedWhole: true },
|
{ rel: '.config/gcloud', seedWhole: true },
|
||||||
{ rel: '.config/opencode', seedWhole: true },
|
{ rel: '.config/opencode', seedWhole: true },
|
||||||
|
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
|
||||||
|
// Docker server image, a headless Linux host), `config.yml` the preferences. An
|
||||||
|
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
|
||||||
|
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
|
||||||
|
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
|
||||||
|
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
|
||||||
|
// that builds gh into the agent image is on, never merely because the file exists.
|
||||||
|
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
|
||||||
|
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
|
||||||
|
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
|
||||||
|
// needed to authenticate; the agent image carries its own extensions outside HOME.
|
||||||
|
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
|
||||||
|
// this carries a sign-in from the Docker server image or a Linux host.
|
||||||
|
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
|
||||||
|
{
|
||||||
|
rel: '.azure',
|
||||||
|
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
|
||||||
|
seedFiles: [
|
||||||
|
'azureProfile.json',
|
||||||
|
'msal_token_cache.json',
|
||||||
|
'service_principal_entries.json',
|
||||||
|
'clouds.config',
|
||||||
|
'config',
|
||||||
|
],
|
||||||
|
},
|
||||||
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
||||||
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
||||||
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
||||||
@@ -853,10 +942,14 @@ const CRED_STORES: CredStorePolicy[] = [
|
|||||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||||
*/
|
*/
|
||||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
export function resolveDockerCredentialArtifacts(
|
||||||
|
home: string = homedir(),
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): DockerClaudeArtifacts {
|
||||||
const mounts: DockerMount[] = [];
|
const mounts: DockerMount[] = [];
|
||||||
const seedCopies: DockerSeedCopy[] = [];
|
const seedCopies: DockerSeedCopy[] = [];
|
||||||
for (const store of CRED_STORES) {
|
for (const store of CRED_STORES) {
|
||||||
|
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
|
||||||
const hostBase = join(home, store.rel);
|
const hostBase = join(home, store.rel);
|
||||||
if (!existsSync(hostBase)) continue;
|
if (!existsSync(hostBase)) continue;
|
||||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||||
@@ -1136,10 +1229,17 @@ function buildAgentImage(
|
|||||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let buildArgPairs: Array<[string, string]>;
|
||||||
|
try {
|
||||||
|
buildArgPairs = agentImageBuildArgPairs();
|
||||||
|
} catch (err) {
|
||||||
|
// A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure.
|
||||||
|
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
||||||
|
}
|
||||||
const argv = dockerEngineArgv(docker);
|
const argv = dockerEngineArgv(docker);
|
||||||
const args = [
|
const args = [
|
||||||
...argv.slice(1),
|
...argv.slice(1),
|
||||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
|
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs),
|
||||||
];
|
];
|
||||||
return new Promise<EnsureImageResult>((resolve) => {
|
return new Promise<EnsureImageResult>((resolve) => {
|
||||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||||
|
|||||||
+30
-6
@@ -195,6 +195,8 @@ export interface CloneOptions {
|
|||||||
/** `--depth 1`: history-less but much faster on large repos. */
|
/** `--depth 1`: history-less but much faster on large repos. */
|
||||||
shallow?: boolean;
|
shallow?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
|
||||||
|
withoutCredentialHelpers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
|
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
|
||||||
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
|
|||||||
|
|
||||||
// ─── Pure: argv + env ────────────────────────────────────────────────────────
|
// ─── Pure: argv + env ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global git options that empty the credential-helper list for one run.
|
||||||
|
*
|
||||||
|
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
|
||||||
|
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
|
||||||
|
* user's own `gh auth setup-git`) would read private repositories on the
|
||||||
|
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
|
||||||
|
* `credential.helper` resets the helper list, and a command-line `-c` is read
|
||||||
|
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
|
||||||
|
* image configures (verified against a real private repo: refs with the helper,
|
||||||
|
* `could not read Username` with it cleared). Public repositories are
|
||||||
|
* unaffected. It must precede the subcommand.
|
||||||
|
*/
|
||||||
|
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* argv for the clone. `--` separates flags from operands so neither the
|
* argv for the clone. `--` separates flags from operands so neither the
|
||||||
* repository nor the destination can ever be read as an option.
|
* repository nor the destination can ever be read as an option.
|
||||||
*/
|
*/
|
||||||
export function buildCloneArgs(opts: CloneOptions): string[] {
|
export function buildCloneArgs(opts: CloneOptions): string[] {
|
||||||
const args = ['clone'];
|
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
|
||||||
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
|
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
|
||||||
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
|
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
|
||||||
if (opts.shallow) args.push('--depth', '1');
|
if (opts.shallow) args.push('--depth', '1');
|
||||||
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
|
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
|
||||||
export function buildLsRemoteArgs(repository: string): string[] {
|
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
|
||||||
return ['ls-remote', '--symref', '--', repository];
|
return [
|
||||||
|
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
|
||||||
|
'ls-remote',
|
||||||
|
'--symref',
|
||||||
|
'--',
|
||||||
|
repository,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
|||||||
return {
|
return {
|
||||||
code: 'AUTH_REQUIRED',
|
code: 'AUTH_REQUIRED',
|
||||||
message:
|
message:
|
||||||
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
|
"That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.",
|
||||||
stderr: clean,
|
stderr: clean,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function probeGitRemote(
|
export async function probeGitRemote(
|
||||||
repository: string,
|
repository: string,
|
||||||
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
|
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
|
||||||
|
opts: { withoutCredentialHelpers?: boolean } = {}
|
||||||
): Promise<GitRemoteProbe> {
|
): Promise<GitRemoteProbe> {
|
||||||
if (!isGitAvailable()) {
|
if (!isGitAvailable()) {
|
||||||
return {
|
return {
|
||||||
@@ -800,7 +824,7 @@ export async function probeGitRemote(
|
|||||||
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
|
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
|
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
|
||||||
if (run.code !== 0 || run.spawnError) {
|
if (run.code !== 0 || run.spawnError) {
|
||||||
return {
|
return {
|
||||||
reachable: false,
|
reachable: false,
|
||||||
|
|||||||
+5
-1
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
|||||||
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
||||||
// windows in which Claude Code could run an empty or non-executable file.
|
// windows in which Claude Code could run an empty or non-executable file.
|
||||||
// rename() swaps the complete, already-executable file in atomically.
|
// rename() swaps the complete, already-executable file in atomically.
|
||||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
|
||||||
|
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
|
||||||
|
// name let the first rename consume the others' temp file, and their ENOENT
|
||||||
|
// dropped those sessions from tmux to the direct-PTY fallback.
|
||||||
|
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||||
await writeFile(tmpPath, desired);
|
await writeFile(tmpPath, desired);
|
||||||
await chmod(tmpPath, 0o755);
|
await chmod(tmpPath, 0o755);
|
||||||
await rename(tmpPath, scriptPath);
|
await rename(tmpPath, scriptPath);
|
||||||
|
|||||||
+679
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview MCP server sync between the enabled agent CLIs.
|
||||||
|
*
|
||||||
|
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
|
||||||
|
* names the file and the dialect). This module reads every participating CLI's list into one
|
||||||
|
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
|
||||||
|
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
|
||||||
|
* own user config.
|
||||||
|
*
|
||||||
|
* Deliberately conservative:
|
||||||
|
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
|
||||||
|
* does not understand) is never rewritten and nothing is ever removed. Same name with a
|
||||||
|
* different definition is reported as a conflict and left alone.
|
||||||
|
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
|
||||||
|
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
|
||||||
|
* switch it on in every other CLI.
|
||||||
|
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
|
||||||
|
* duplicate table) is never written, and a write is only made after the NEW text has been
|
||||||
|
* parsed again and every added server comes back as intended.
|
||||||
|
* - Only the MCP table is touched; every other key in the file is preserved. Files are
|
||||||
|
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
|
||||||
|
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
|
||||||
|
* (overwritten by each sync).
|
||||||
|
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
|
||||||
|
* readable by its owner only.
|
||||||
|
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
|
||||||
|
* - Only one apply runs at a time.
|
||||||
|
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
|
||||||
|
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
|
||||||
|
* cannot be located safely, so that target is reported `skipped` and never written.
|
||||||
|
*
|
||||||
|
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
|
||||||
|
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
|
||||||
|
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
|
||||||
|
*
|
||||||
|
* @module mcp-sync
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join } from 'node:path';
|
||||||
|
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||||
|
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||||
|
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
||||||
|
|
||||||
|
export type McpFormat = McpConfigFormat;
|
||||||
|
|
||||||
|
export interface McpServer {
|
||||||
|
transport: 'stdio' | 'http' | 'sse';
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/** Switched off in the CLI that defines it. Never propagated. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpServerMap = Record<string, McpServer>;
|
||||||
|
|
||||||
|
export interface McpSyncTarget {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Home-relative default location of the config file. */
|
||||||
|
path: string;
|
||||||
|
format: McpFormat;
|
||||||
|
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
||||||
|
relocation?: { envVar: string; path: string };
|
||||||
|
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second apply was requested while one was running. */
|
||||||
|
export class McpSyncBusyError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('An MCP sync is already running');
|
||||||
|
this.name = 'McpSyncBusyError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error whose message this module wrote itself. It names keys Codeman chose and server names
|
||||||
|
* (which the result reports anyway), never a value from the file, so it may be shown as is.
|
||||||
|
*/
|
||||||
|
class McpConfigError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'McpConfigError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
|
||||||
|
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
|
||||||
|
* "Unexpected token" errors quote about ten characters of source. These files hold env values
|
||||||
|
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
|
||||||
|
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
|
||||||
|
* else by a fixed category.
|
||||||
|
*/
|
||||||
|
function describeMcpSyncError(err: unknown): string {
|
||||||
|
if (err instanceof McpConfigError) return err.message;
|
||||||
|
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
|
||||||
|
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
|
||||||
|
const pos = /at position (\d+)/.exec(err.message);
|
||||||
|
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
|
||||||
|
}
|
||||||
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||||
|
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
|
||||||
|
return 'unexpected error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
|
||||||
|
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
|
||||||
|
function dict<T>(): Record<string, T> {
|
||||||
|
return Object.create(null) as Record<string, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own, safe keys of an untrusted table. */
|
||||||
|
function safeKeys(table: Record<string, unknown>): string[] {
|
||||||
|
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
function strMap(v: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isRecord(v)) return undefined;
|
||||||
|
const out = dict<string>();
|
||||||
|
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strArr(v: unknown): string[] | undefined {
|
||||||
|
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop undefined/empty fields so equal servers compare equal. */
|
||||||
|
function clean(s: McpServer): McpServer {
|
||||||
|
const out: McpServer = { transport: s.transport };
|
||||||
|
if (s.command) out.command = s.command;
|
||||||
|
if (s.args?.length) out.args = s.args;
|
||||||
|
if (s.env && Object.keys(s.env).length) out.env = s.env;
|
||||||
|
if (s.cwd) out.cwd = s.cwd;
|
||||||
|
if (s.url) out.url = s.url;
|
||||||
|
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
|
||||||
|
if (s.disabled) out.disabled = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
|
||||||
|
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||||
|
|
||||||
|
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
|
||||||
|
function fingerprint(s: McpServer): string {
|
||||||
|
const t = s.transport === 'stdio' ? 'stdio' : 'url';
|
||||||
|
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
|
||||||
|
function fullIdentity(s: McpServer): string {
|
||||||
|
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON dialects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fromClaude(raw: unknown): McpServer | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const type = raw.type;
|
||||||
|
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
|
||||||
|
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
|
||||||
|
}
|
||||||
|
if (typeof raw.command === 'string') {
|
||||||
|
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toClaude(s: McpServer): Record<string, unknown> {
|
||||||
|
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
|
||||||
|
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromGemini(raw: unknown): McpServer | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
||||||
|
// today, and a bare `url` with no type is the legacy SSE form.
|
||||||
|
if (typeof raw.httpUrl === 'string')
|
||||||
|
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
||||||
|
if (typeof raw.url === 'string') {
|
||||||
|
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
|
||||||
|
}
|
||||||
|
if (typeof raw.command === 'string') {
|
||||||
|
return clean({
|
||||||
|
transport: 'stdio',
|
||||||
|
command: raw.command,
|
||||||
|
args: strArr(raw.args),
|
||||||
|
env: strMap(raw.env),
|
||||||
|
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGemini(s: McpServer): Record<string, unknown> {
|
||||||
|
if (s.transport === 'stdio') {
|
||||||
|
return {
|
||||||
|
command: s.command,
|
||||||
|
args: s.args ?? [],
|
||||||
|
...(s.env ? { env: s.env } : {}),
|
||||||
|
...(s.cwd ? { cwd: s.cwd } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
|
||||||
|
function fromAntigravity(raw: unknown): McpServer | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const disabled = raw.disabled === true;
|
||||||
|
if (typeof raw.serverUrl === 'string')
|
||||||
|
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
|
||||||
|
if (typeof raw.command === 'string') {
|
||||||
|
return clean({
|
||||||
|
transport: 'stdio',
|
||||||
|
command: raw.command,
|
||||||
|
args: strArr(raw.args),
|
||||||
|
env: strMap(raw.env),
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||||
|
if (s.transport === 'sse') return null;
|
||||||
|
if (s.transport === 'stdio') {
|
||||||
|
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
|
||||||
|
}
|
||||||
|
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromOpencode(raw: unknown): McpServer | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const disabled = raw.enabled === false;
|
||||||
|
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||||
|
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||||
|
}
|
||||||
|
if (raw.type === 'local') {
|
||||||
|
const cmd = strArr(raw.command);
|
||||||
|
if (!cmd?.length) return null;
|
||||||
|
return clean({
|
||||||
|
transport: 'stdio',
|
||||||
|
command: cmd[0],
|
||||||
|
args: cmd.slice(1),
|
||||||
|
env: strMap(raw.environment),
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpencode(s: McpServer): Record<string, unknown> {
|
||||||
|
if (s.transport === 'stdio') {
|
||||||
|
return {
|
||||||
|
type: 'local',
|
||||||
|
command: [s.command, ...(s.args ?? [])],
|
||||||
|
...(s.env ? { environment: s.env } : {}),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonDialect {
|
||||||
|
/** Key holding the server table. */
|
||||||
|
key: string;
|
||||||
|
from(raw: unknown): McpServer | null;
|
||||||
|
to(s: McpServer): Record<string, unknown> | null;
|
||||||
|
/** Top-level keys to seed when creating the file from nothing. */
|
||||||
|
seed?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||||
|
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
||||||
|
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
||||||
|
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
||||||
|
'opencode-json': {
|
||||||
|
key: 'mcp',
|
||||||
|
from: fromOpencode,
|
||||||
|
to: toOpencode,
|
||||||
|
seed: { $schema: 'https://opencode.ai/config.json' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Codex TOML (the `[mcp_servers.*]` tables only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fromCodex(t: Record<string, unknown>): McpServer | null {
|
||||||
|
const disabled = t.enabled === false;
|
||||||
|
if (typeof t.url === 'string') {
|
||||||
|
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
|
||||||
|
}
|
||||||
|
if (typeof t.command === 'string') {
|
||||||
|
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomlStr = (v: string): string => JSON.stringify(v);
|
||||||
|
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
|
||||||
|
|
||||||
|
function toCodexToml(name: string, s: McpServer): string {
|
||||||
|
const head = `[mcp_servers.${tomlKey(name)}]`;
|
||||||
|
const lines = [head];
|
||||||
|
if (s.transport === 'stdio') {
|
||||||
|
lines.push(`command = ${tomlStr(s.command ?? '')}`);
|
||||||
|
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
|
||||||
|
if (s.env) {
|
||||||
|
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
|
||||||
|
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(`url = ${tomlStr(s.url ?? '')}`);
|
||||||
|
if (s.headers) {
|
||||||
|
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
|
||||||
|
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dialect entry points
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ParsedConfig {
|
||||||
|
/** Servers this module understands. */
|
||||||
|
servers: McpServerMap;
|
||||||
|
/** Every name defined under the MCP table, in any shape: these are never appended over. */
|
||||||
|
names: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||||
|
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
|
||||||
|
if (text === null || !text.trim()) return dict<unknown>();
|
||||||
|
if (format === 'codex-toml') {
|
||||||
|
const doc = parseToml(text);
|
||||||
|
const table = doc.mcp_servers;
|
||||||
|
if (table === undefined) return dict<unknown>();
|
||||||
|
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
const dialect = JSON_DIALECTS[format];
|
||||||
|
const doc: unknown = JSON.parse(text);
|
||||||
|
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
|
||||||
|
const table = doc[dialect.key];
|
||||||
|
if (table === undefined) return dict<unknown>();
|
||||||
|
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||||
|
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
|
||||||
|
const table = mcpTable(format, text);
|
||||||
|
const servers = dict<McpServer>();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const name of safeKeys(table)) {
|
||||||
|
names.add(name);
|
||||||
|
const raw = table[name];
|
||||||
|
const s =
|
||||||
|
format === 'codex-toml'
|
||||||
|
? isRecord(raw)
|
||||||
|
? fromCodex(raw)
|
||||||
|
: null
|
||||||
|
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
|
||||||
|
if (s) servers[name] = s;
|
||||||
|
}
|
||||||
|
return { servers, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The servers of a config file's text. */
|
||||||
|
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
|
||||||
|
return parseConfig(format, text).servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this dialect can express the server. */
|
||||||
|
function canExpress(format: McpFormat, s: McpServer): boolean {
|
||||||
|
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add servers to a config file's text and return the new text. A name already defined under the
|
||||||
|
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
|
||||||
|
* come back as intended, otherwise this throws and nothing should be written.
|
||||||
|
*/
|
||||||
|
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
|
||||||
|
const before = parseConfig(format, text);
|
||||||
|
const todo = dict<McpServer>();
|
||||||
|
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
|
||||||
|
const names = Object.keys(todo);
|
||||||
|
if (names.length === 0) return text ?? '';
|
||||||
|
|
||||||
|
let out: string;
|
||||||
|
if (format === 'codex-toml') {
|
||||||
|
const base = text ?? '';
|
||||||
|
const eol = base.includes('\r\n') ? '\r\n' : '\n';
|
||||||
|
const sep =
|
||||||
|
base.length === 0
|
||||||
|
? ''
|
||||||
|
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
|
||||||
|
? ''
|
||||||
|
: base.endsWith('\n')
|
||||||
|
? eol
|
||||||
|
: eol + eol;
|
||||||
|
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
|
||||||
|
out = base + sep + blocks.join(eol);
|
||||||
|
} else {
|
||||||
|
const dialect = JSON_DIALECTS[format];
|
||||||
|
const doc: Record<string, unknown> =
|
||||||
|
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
|
||||||
|
const existing = doc[dialect.key];
|
||||||
|
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||||
|
for (const n of names) {
|
||||||
|
const entry = dialect.to(todo[n]);
|
||||||
|
if (entry) table[n] = entry;
|
||||||
|
}
|
||||||
|
doc[dialect.key] = table;
|
||||||
|
out = JSON.stringify(doc, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read what we are about to write.
|
||||||
|
const after = parseConfig(format, out);
|
||||||
|
for (const n of before.names) {
|
||||||
|
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
|
||||||
|
}
|
||||||
|
for (const n of names) {
|
||||||
|
const got = after.servers[n];
|
||||||
|
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
|
||||||
|
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Orchestration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function readText(file: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(file, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
|
||||||
|
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
|
||||||
|
* readable by its owner only.
|
||||||
|
*/
|
||||||
|
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
|
||||||
|
let target = file;
|
||||||
|
try {
|
||||||
|
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
|
||||||
|
try {
|
||||||
|
await fs.lstat(file);
|
||||||
|
throw new McpConfigError('config path is a dangling symlink');
|
||||||
|
} catch (inner) {
|
||||||
|
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = 0o600;
|
||||||
|
try {
|
||||||
|
mode = (await fs.stat(target)).mode & 0o777;
|
||||||
|
await fs.copyFile(target, `${target}.codeman-bak`);
|
||||||
|
await fs.chmod(`${target}.codeman-bak`, 0o600);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
if (secret) mode &= ~0o077;
|
||||||
|
|
||||||
|
await fs.mkdir(dirname(target), { recursive: true });
|
||||||
|
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tmp, text, { mode });
|
||||||
|
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
|
||||||
|
await fs.chmod(tmp, mode);
|
||||||
|
await fs.rename(tmp, target);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.unlink(tmp).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpSyncOptions {
|
||||||
|
/** false = report what would change without writing. */
|
||||||
|
apply: boolean;
|
||||||
|
home?: string;
|
||||||
|
/**
|
||||||
|
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
|
||||||
|
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
|
||||||
|
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
|
||||||
|
* env can never aim a write outside that home.
|
||||||
|
*/
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The config file a target means, honouring its relocation env var. `skip` is set when the var
|
||||||
|
* holds something that cannot be located safely (a relative path resolves against the CLI's
|
||||||
|
* working directory, which differs per session), so the target is neither read nor written.
|
||||||
|
*/
|
||||||
|
function resolveFile(
|
||||||
|
t: McpSyncTarget,
|
||||||
|
home: string,
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
): { file: string; skip?: string } {
|
||||||
|
const rel = t.relocation;
|
||||||
|
const dir = rel ? env[rel.envVar] : undefined;
|
||||||
|
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
|
||||||
|
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
|
||||||
|
if (!isAbsolute(dir)) {
|
||||||
|
return {
|
||||||
|
file: `$${rel.envVar}/${rel.path}`,
|
||||||
|
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { file: join(dir, rel.path) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let applying = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
|
||||||
|
* order: when two CLIs define a name differently, the first one's definition is the one copied).
|
||||||
|
* Throws `McpSyncBusyError` if another apply is running.
|
||||||
|
*/
|
||||||
|
export async function syncMcpServers(
|
||||||
|
targets: McpSyncTarget[],
|
||||||
|
opts: McpSyncOptions,
|
||||||
|
unsupported: string[] = []
|
||||||
|
): Promise<McpSyncResult> {
|
||||||
|
if (opts.apply) {
|
||||||
|
if (applying) throw new McpSyncBusyError();
|
||||||
|
applying = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await run(targets, opts, unsupported);
|
||||||
|
} finally {
|
||||||
|
if (opts.apply) applying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
|
||||||
|
const home = opts.home ?? homedir();
|
||||||
|
const env = opts.env ?? (opts.home === undefined ? process.env : {});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const live = targets
|
||||||
|
.map((t) => ({ t, ...resolveFile(t, home, env) }))
|
||||||
|
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
|
||||||
|
|
||||||
|
const state = live.map(({ t, file, skip }) => {
|
||||||
|
const res: McpSyncTargetResult = {
|
||||||
|
id: t.id,
|
||||||
|
label: t.label,
|
||||||
|
file,
|
||||||
|
status: skip ? 'skipped' : 'ok',
|
||||||
|
...(skip ? { error: skip } : {}),
|
||||||
|
servers: [],
|
||||||
|
added: [],
|
||||||
|
skipped: [],
|
||||||
|
};
|
||||||
|
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const s of state) {
|
||||||
|
if (s.res.status !== 'ok') continue;
|
||||||
|
try {
|
||||||
|
if (!s.t.installed && !(await exists(s.file))) {
|
||||||
|
s.res.status = 'absent';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||||
|
s.servers = parsed.servers;
|
||||||
|
s.names = parsed.names;
|
||||||
|
s.res.servers = [...parsed.names];
|
||||||
|
} catch (err) {
|
||||||
|
s.res.status = 'unreadable';
|
||||||
|
s.res.error = describeMcpSyncError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
|
||||||
|
const union = dict<McpServer>();
|
||||||
|
const conflicts = new Set<string>();
|
||||||
|
const switchedOff = new Set<string>();
|
||||||
|
for (const s of state) {
|
||||||
|
if (s.res.status !== 'ok') continue;
|
||||||
|
for (const name of Object.keys(s.servers)) {
|
||||||
|
const def = s.servers[name];
|
||||||
|
if (def.disabled) {
|
||||||
|
switchedOff.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(name in union)) union[name] = def;
|
||||||
|
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
|
||||||
|
|
||||||
|
for (const s of state) {
|
||||||
|
if (s.res.status !== 'ok') continue;
|
||||||
|
const add = dict<McpServer>();
|
||||||
|
for (const name of Object.keys(union)) {
|
||||||
|
if (s.names.has(name)) continue;
|
||||||
|
if (canExpress(s.t.format, union[name])) add[name] = union[name];
|
||||||
|
else s.res.skipped.push(name);
|
||||||
|
}
|
||||||
|
s.res.added = Object.keys(add);
|
||||||
|
if (!opts.apply || s.res.added.length === 0) continue;
|
||||||
|
try {
|
||||||
|
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
|
||||||
|
const fresh = await readText(s.file);
|
||||||
|
const out = addServers(s.t.format, fresh, add);
|
||||||
|
const current = parseConfig(s.t.format, fresh);
|
||||||
|
const written = Object.keys(add).filter((n) => !current.names.has(n));
|
||||||
|
if (written.length === 0) {
|
||||||
|
s.res.added = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const subset = dict<McpServer>();
|
||||||
|
for (const n of written) subset[n] = add[n];
|
||||||
|
await writeAtomic(s.file, out, carriesSecrets(subset));
|
||||||
|
s.res.added = written;
|
||||||
|
} catch (err) {
|
||||||
|
s.res.status = 'failed';
|
||||||
|
s.res.error = describeMcpSyncError(err);
|
||||||
|
s.res.added = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: opts.apply,
|
||||||
|
targets: state.map((s) => s.res),
|
||||||
|
conflicts: [...conflicts].sort(),
|
||||||
|
disabled,
|
||||||
|
unsupported,
|
||||||
|
};
|
||||||
|
}
|
||||||
+64
-1
@@ -24,6 +24,7 @@ import type {
|
|||||||
OmpConfig,
|
OmpConfig,
|
||||||
SessionRemote,
|
SessionRemote,
|
||||||
SessionDocker,
|
SessionDocker,
|
||||||
|
PaneExit,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,23 @@ export interface MuxSession {
|
|||||||
respawnConfig?: PersistedRespawnConfig;
|
respawnConfig?: PersistedRespawnConfig;
|
||||||
/** Whether Ralph / Todo tracking is enabled */
|
/** Whether Ralph / Todo tracking is enabled */
|
||||||
ralphEnabled?: boolean;
|
ralphEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||||
|
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||||
|
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
|
||||||
|
* entry either, which means a remote or docker session rediscovered this way
|
||||||
|
* arrives with no `remote`/`docker` metadata and looks local. Anything that
|
||||||
|
* would be WRONG about such a session rather than merely vague must fail
|
||||||
|
* closed on this flag.
|
||||||
|
*
|
||||||
|
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
|
||||||
|
* session: `saveSessions()` serializes the whole record to
|
||||||
|
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
|
||||||
|
* session rediscovered once stays opted out of everything keyed on this for
|
||||||
|
* the life of that record. That is the safe direction to fail, and it costs
|
||||||
|
* only the guess Codeman is declining to make.
|
||||||
|
*/
|
||||||
|
discovered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +90,13 @@ export interface CreateSessionOptions {
|
|||||||
workingDir: string;
|
workingDir: string;
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
|
||||||
|
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||||
|
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||||
|
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||||
|
*/
|
||||||
|
cliName?: string;
|
||||||
niceConfig?: NiceConfig;
|
niceConfig?: NiceConfig;
|
||||||
model?: string;
|
model?: string;
|
||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
@@ -90,6 +115,8 @@ export interface CreateSessionOptions {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
|
||||||
|
advisorModel?: string;
|
||||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||||
@@ -105,8 +132,15 @@ export interface RespawnPaneOptions {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
|
/** Session display name (tab name). */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
|
||||||
|
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||||
|
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||||
|
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||||
|
*/
|
||||||
|
cliName?: string;
|
||||||
niceConfig?: NiceConfig;
|
niceConfig?: NiceConfig;
|
||||||
model?: string;
|
model?: string;
|
||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
@@ -132,6 +166,8 @@ export interface RespawnPaneOptions {
|
|||||||
unsetEnvKeys?: string[];
|
unsetEnvKeys?: string[];
|
||||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
|
||||||
|
advisorModel?: string;
|
||||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||||
@@ -180,6 +216,7 @@ export interface PaneCaptureOptions {
|
|||||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||||
|
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||||
*/
|
*/
|
||||||
export interface TerminalMultiplexer extends EventEmitter {
|
export interface TerminalMultiplexer extends EventEmitter {
|
||||||
/** Which backend this instance uses */
|
/** Which backend this instance uses */
|
||||||
@@ -294,6 +331,32 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
|
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
|
||||||
isPaneDead(muxName: string): boolean;
|
isPaneDead(muxName: string): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the last pane read saw of this session's agent, or `undefined` for
|
||||||
|
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
|
||||||
|
* reads a map the batched watcher fills, so it answers no fresher than that
|
||||||
|
* watcher's interval and the three synchronous `isPaneDead()` callers still
|
||||||
|
* need their own probe. See {@link PaneExit}.
|
||||||
|
*/
|
||||||
|
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||||
|
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||||
|
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
|
||||||
|
* and a multiplexer without this method never has a session closed by it.
|
||||||
|
*/
|
||||||
|
getPaneExitReadCount?(muxName: string): number;
|
||||||
|
|
||||||
|
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
||||||
|
clearPaneExit?(muxName: string): void;
|
||||||
|
|
||||||
|
/** Start polling every pane on the socket for an exited agent. */
|
||||||
|
startPaneExitWatcher?(intervalMs?: number): void;
|
||||||
|
|
||||||
|
/** Stop the pane-exit watcher. */
|
||||||
|
stopPaneExitWatcher?(): void;
|
||||||
|
|
||||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
|
||||||
|
*
|
||||||
|
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
|
||||||
|
* CLI while the pane, the tmux session and the `tmux attach-session` process
|
||||||
|
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
|
||||||
|
* module decides when such a session is closed, the way the X button closes
|
||||||
|
* it, so finished sessions stop piling up on the board.
|
||||||
|
*
|
||||||
|
* The rule closes a session only on a POSITIVE observation of a clean exit:
|
||||||
|
*
|
||||||
|
* - The exit status must be an explicit numeric 0 with no signal. An absent
|
||||||
|
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
|
||||||
|
* status nor a signal, so reading absence as clean would sweep an agent the
|
||||||
|
* OOM killer took. A non-zero status or any signal keeps the row, marked with
|
||||||
|
* the exit, as the crash evidence #210 was filed to keep.
|
||||||
|
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
|
||||||
|
* have agreed on that exit. A failed, empty or skipped read counts for
|
||||||
|
* nothing, because unknown never closes anything.
|
||||||
|
* - No start, attach or relaunch may be in flight for the session. The
|
||||||
|
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
|
||||||
|
* pane on purpose, and for a few seconds that pane still reads as dead.
|
||||||
|
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
|
||||||
|
* the last start, attach or relaunch finished. A CLI that prints a startup
|
||||||
|
* error ("not logged in", a bad profile, a config error) and exits 0 would
|
||||||
|
* otherwise lose its tab, and the error with it, seconds after launch. Its
|
||||||
|
* row stays, marked `exited (0)`, for the user to read and close.
|
||||||
|
*
|
||||||
|
* Scoping to local mux-backed sessions happens before this rule runs:
|
||||||
|
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
|
||||||
|
* docker and discovered sessions, so their `paneExit` never reaches here.
|
||||||
|
*
|
||||||
|
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
|
||||||
|
*/
|
||||||
|
import type { PaneExit } from './types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many authoritative pane reads must agree on a clean exit before the
|
||||||
|
* session is closed. At the watcher's 2 s cadence two reads mean a finished
|
||||||
|
* session disappears within about four seconds of its agent exiting.
|
||||||
|
*/
|
||||||
|
export const CLEAN_EXIT_CONFIRMING_READS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a pane must have been up before a clean exit closes its session.
|
||||||
|
* An exit sooner than this after the last pane start is read as a startup
|
||||||
|
* failure rather than a user ending the agent, and the row is kept.
|
||||||
|
*/
|
||||||
|
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
|
||||||
|
|
||||||
|
/** The lifecycle-log reason recorded when the sweep closes a session. */
|
||||||
|
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this exit a clean one? True only for an explicit numeric status of 0 with
|
||||||
|
* no signal reported.
|
||||||
|
*
|
||||||
|
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
|
||||||
|
* clean". An absent status is how a signal death presents on tmux 3.2a, and
|
||||||
|
* that shortcut would close crashed agents with nothing failing to warn you.
|
||||||
|
*/
|
||||||
|
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
|
||||||
|
if (!exit) return false;
|
||||||
|
if (exit.signal !== undefined) return false;
|
||||||
|
return exit.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the sweep needs to know about one session. */
|
||||||
|
export interface CleanExitSweepCandidate {
|
||||||
|
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
|
||||||
|
paneExit: PaneExit | undefined;
|
||||||
|
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
|
||||||
|
confirmingReads: number;
|
||||||
|
/** A start, attach or relaunch is running for this session's pane. */
|
||||||
|
paneLifecycleInFlight: boolean;
|
||||||
|
/** The session is already being closed or detached. */
|
||||||
|
closing: boolean;
|
||||||
|
/**
|
||||||
|
* When the last start, attach or relaunch of this pane finished
|
||||||
|
* (`Session.paneStartedAt`), or 0 when none has run in this process.
|
||||||
|
*/
|
||||||
|
paneStartedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Should the sweep close this session now? See the file overview for the rule. */
|
||||||
|
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
|
||||||
|
if (candidate.closing) return false;
|
||||||
|
if (candidate.paneLifecycleInFlight) return false;
|
||||||
|
if (!isCleanPaneExit(candidate.paneExit)) return false;
|
||||||
|
// `at` is when this server first read the pane dead, so an exit during the
|
||||||
|
// start itself lands BEFORE `paneStartedAt` and is kept too.
|
||||||
|
if (
|
||||||
|
candidate.paneStartedAt > 0 &&
|
||||||
|
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
|
||||||
|
}
|
||||||
+27
-14
@@ -19,19 +19,22 @@
|
|||||||
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
||||||
* `busy` and stays eligible.
|
* `busy` and stays eligible.
|
||||||
*
|
*
|
||||||
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
|
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
|
||||||
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
|
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
|
||||||
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
|
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
|
||||||
* `tmux attach-session` process, which stays alive throughout — so no exit
|
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
|
||||||
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
|
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
|
||||||
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
|
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
|
||||||
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
|
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
|
||||||
* notice the dead pane; until a record can say the agent is gone, this pass will
|
* with status 0 through `cleanupSession()`, which leaves the durable record
|
||||||
* offer those sessions back, and the user dismisses or closes them.
|
* described above. This module also refuses a record whose persisted
|
||||||
|
* `paneExit` is a clean exit, which covers a session that exited moments
|
||||||
|
* before the power went, before the sweep reached it. A crashed agent's record
|
||||||
|
* stays eligible, like the row the sweep leaves on the board for it.
|
||||||
*
|
*
|
||||||
* The `pid` check below is therefore NOT that rule. It refuses a record whose
|
* The `pid` check below is NOT that rule. It refuses a record whose attach
|
||||||
* attach process was already gone, which is a session that never started or
|
* process was already gone, which is a session that never started or whose
|
||||||
* whose pane died outright.
|
* pane died outright.
|
||||||
*
|
*
|
||||||
* @dependencies types (SessionState), config/cli-registry
|
* @dependencies types (SessionState), config/cli-registry
|
||||||
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
|
|
||||||
import type { SessionState } from './types.js';
|
import type { SessionState } from './types.js';
|
||||||
import { getCli } from './config/cli-registry/registry.js';
|
import { getCli } from './config/cli-registry/registry.js';
|
||||||
|
import { isCleanPaneExit } from './pane-exit-sweep.js';
|
||||||
|
|
||||||
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
||||||
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
||||||
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
|
|||||||
/**
|
/**
|
||||||
* Why one session was passed over. Reported for logging and shown to the user.
|
* Why one session was passed over. Reported for logging and shown to the user.
|
||||||
*
|
*
|
||||||
* The first seven are decided before anything is built. `capacity-reached` and
|
* All but the last two are decided before anything is built. `capacity-reached` and
|
||||||
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
||||||
* are the two the banner must not confuse with a missing workspace: one means
|
* are the two the banner must not confuse with a missing workspace: one means
|
||||||
* "try again after closing something", the other means the CLI would not start.
|
* "try again after closing something", the other means the CLI would not start.
|
||||||
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
|
|||||||
| 'no-persisted-record'
|
| 'no-persisted-record'
|
||||||
| 'intentionally-ended'
|
| 'intentionally-ended'
|
||||||
| 'not-running'
|
| 'not-running'
|
||||||
|
| 'agent-exited'
|
||||||
| 'respawn-blocked'
|
| 'respawn-blocked'
|
||||||
| 'remote-or-docker'
|
| 'remote-or-docker'
|
||||||
| 'unsupported-mode'
|
| 'unsupported-mode'
|
||||||
@@ -191,7 +196,8 @@ export function planRebootRestore(
|
|||||||
//
|
//
|
||||||
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
||||||
// module header: that leaves the pid in place, because the pid is the tmux
|
// module header: that leaves the pid in place, because the pid is the tmux
|
||||||
// attach process and `remain-on-exit` keeps it alive.
|
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
|
||||||
|
// below catches it instead.
|
||||||
//
|
//
|
||||||
// Conservative on purpose. A session that somehow persisted no pid while
|
// Conservative on purpose. A session that somehow persisted no pid while
|
||||||
// genuinely running is not offered, and its conversation stays reachable
|
// genuinely running is not offered, and its conversation stays reachable
|
||||||
@@ -200,6 +206,13 @@ export function planRebootRestore(
|
|||||||
skipped.push({ sessionId, reason: 'not-running' });
|
skipped.push({ sessionId, reason: 'not-running' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isCleanPaneExit(state.paneExit)) {
|
||||||
|
// The user ended the agent, and the clean-exit sweep would have closed the
|
||||||
|
// session had the power not gone first (Ark0N/Codeman#446). The same
|
||||||
|
// explicit-0 rule applies: an absent status is unknown, not clean.
|
||||||
|
skipped.push({ sessionId, reason: 'agent-exited' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (state.respawnBlocked === true) {
|
if (state.respawnBlocked === true) {
|
||||||
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
||||||
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
* in 12/12 windows and the four idle ones in 0/12.
|
* in 12/12 windows and the four idle ones in 0/12.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stripAnsi } from './utils/regex-patterns.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A gap longer than this ends a run of continuous output. Claude repaints at
|
* A gap longer than this ends a run of continuous output. Claude repaints at
|
||||||
* least once a second while working, so this leaves generous headroom.
|
* least once a second while working, so this leaves generous headroom.
|
||||||
@@ -91,3 +93,124 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
|
|||||||
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
||||||
return now - lastActivityAt >= silenceMs;
|
return now - lastActivityAt >= silenceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many rows at the foot of a pane capture may hold the background-work row, for a
|
||||||
|
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
|
||||||
|
*
|
||||||
|
* One, because the tightest window is the right default and Claude Code needs no more:
|
||||||
|
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
|
||||||
|
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
|
||||||
|
* BELOW its chip loses the badge rather than gaining a hole.
|
||||||
|
*
|
||||||
|
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
|
||||||
|
* it excludes first is the one that taught us so: Claude's status line sits directly
|
||||||
|
* above the footer, its content comes from a `statusLine` command, and a session running
|
||||||
|
* with permissions bypassed can write that command into `.claude/settings.json` in its
|
||||||
|
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
|
||||||
|
* of its own and silence its own idle alert. Every row added here is another row
|
||||||
|
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
|
||||||
|
* and never to a whole-pane search.
|
||||||
|
*/
|
||||||
|
export const WATCHING_TAIL_LINES = 1;
|
||||||
|
|
||||||
|
/** Longest label a badge will carry. A footer chip is a handful of words. */
|
||||||
|
export const MAX_WATCHING_LABEL_CHARS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
|
||||||
|
*
|
||||||
|
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
|
||||||
|
* started is still going, which is exactly the case where the agent has ended its turn
|
||||||
|
* without wanting anything from the user. `pattern` comes from the CLI's own registry
|
||||||
|
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
|
||||||
|
* declares one, and the whole match stands in when it does not.
|
||||||
|
*
|
||||||
|
* Each candidate row is tested on its own, bottom row first, so a pattern can anchor
|
||||||
|
* itself with `^` or `$` against a single row rather than against a joined block. Blank
|
||||||
|
* rows are dropped before the window is taken, because a CLI that leaves a blank line
|
||||||
|
* between its chrome rows would otherwise spend the window on nothing. The answer is
|
||||||
|
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
|
||||||
|
*
|
||||||
|
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
|
||||||
|
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
|
||||||
|
* @returns the label, or null when the pane shows no background work
|
||||||
|
*/
|
||||||
|
export function watchingLabel(
|
||||||
|
paneText: string | null | undefined,
|
||||||
|
pattern: RegExp,
|
||||||
|
tailLines: number = WATCHING_TAIL_LINES
|
||||||
|
): string | null {
|
||||||
|
if (!paneText) return null;
|
||||||
|
const lines = stripAnsi(paneText)
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.filter((line) => line !== '');
|
||||||
|
for (const line of lines.slice(-Math.max(1, tailLines)).reverse()) {
|
||||||
|
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
|
||||||
|
// caller reaching in from a test or a config reload might, and a stale lastIndex
|
||||||
|
// would make the same screen match every other call.
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
const match = pattern.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
|
||||||
|
if (label) return label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
|
||||||
|
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
|
||||||
|
* transcript to find an old turn's row.
|
||||||
|
*/
|
||||||
|
export const AWAITING_SEARCH_ROWS = 6;
|
||||||
|
|
||||||
|
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
|
||||||
|
const COMPOSER_FRAME_ROW = /^[─-╿]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
|
||||||
|
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
|
||||||
|
*
|
||||||
|
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
|
||||||
|
* nothing is being asked of the user: the CLI resumes by itself when the workers report
|
||||||
|
* back. That is why a session in this state counts as working.
|
||||||
|
*
|
||||||
|
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
|
||||||
|
* updates it, so after the workers finish the same words are still on screen above the
|
||||||
|
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
|
||||||
|
* long as they stay visible. Only the newest transcript row counts: the walk starts at
|
||||||
|
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
|
||||||
|
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
|
||||||
|
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
|
||||||
|
* its own there, so the stale copy is never the one tested.
|
||||||
|
*
|
||||||
|
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
|
||||||
|
* @returns false when the screen shows no composer, which is no evidence either way
|
||||||
|
*/
|
||||||
|
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
|
||||||
|
if (!paneText) return false;
|
||||||
|
const rows = stripAnsi(paneText)
|
||||||
|
.split('\n')
|
||||||
|
.map((row) => row.trimEnd());
|
||||||
|
let composer = -1;
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
|
||||||
|
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
|
||||||
|
composer = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (composer < 0) return false;
|
||||||
|
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
|
||||||
|
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
return pattern.test(row);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||||
import { isEffortLevel } from './types.js';
|
import { isAdvisorModel, isEffortLevel } from './types.js';
|
||||||
import { getAugmentedPath } from './utils/index.js';
|
import { getAugmentedPath } from './utils/index.js';
|
||||||
import { compareVersions } from './utils/dependency-checker.js';
|
import { compareVersions } from './utils/dependency-checker.js';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
@@ -54,6 +54,25 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
|||||||
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
|
||||||
|
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
|
||||||
|
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
|
||||||
|
*
|
||||||
|
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
|
||||||
|
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
|
||||||
|
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
|
||||||
|
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
|
||||||
|
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
|
||||||
|
* `/advisor` still switches or turns it off inside the running session.
|
||||||
|
*
|
||||||
|
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
|
||||||
|
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
|
||||||
|
*/
|
||||||
|
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
|
||||||
|
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
|
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
|
||||||
* that ships cross-session messaging (the feature that makes the peer name matter),
|
* that ships cross-session messaging (the feature that makes the peer name matter),
|
||||||
@@ -111,6 +130,7 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
|
|||||||
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
||||||
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
|
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
|
||||||
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
|
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
|
||||||
|
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
|
||||||
* @returns Array of CLI arguments
|
* @returns Array of CLI arguments
|
||||||
*/
|
*/
|
||||||
export function buildInteractiveArgs(
|
export function buildInteractiveArgs(
|
||||||
@@ -120,11 +140,21 @@ export function buildInteractiveArgs(
|
|||||||
allowedTools?: string,
|
allowedTools?: string,
|
||||||
effort?: EffortLevel,
|
effort?: EffortLevel,
|
||||||
sessionName?: string,
|
sessionName?: string,
|
||||||
cliVersion?: string | null
|
cliVersion?: string | null,
|
||||||
|
advisorModel?: string
|
||||||
): string[] {
|
): string[] {
|
||||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||||
if (model) args.push('--model', model);
|
if (model) args.push('--model', model);
|
||||||
args.push(...buildEffortCliArgs(effort));
|
const effortArgs = buildEffortCliArgs(effort);
|
||||||
|
const advisor = buildAdvisorSettings(advisorModel);
|
||||||
|
if (advisor.advisorModel === undefined) {
|
||||||
|
args.push(...effortArgs);
|
||||||
|
} else if (effortArgs[0] === '--settings') {
|
||||||
|
// One --settings flag only: fold the advisor into ultracode's JSON object.
|
||||||
|
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
|
||||||
|
} else {
|
||||||
|
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
|
||||||
|
}
|
||||||
args.push(...buildNameCliArgs(sessionName, cliVersion));
|
args.push(...buildNameCliArgs(sessionName, cliVersion));
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import type { CliEntry } from './config/cli-registry/types.js';
|
import type { CliEntry } from './config/cli-registry/types.js';
|
||||||
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
||||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||||
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
||||||
import { compareVersions } from './utils/dependency-checker.js';
|
import { compareVersions } from './utils/dependency-checker.js';
|
||||||
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
|
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
|
||||||
import { launcherDefaultTarget } from './utils/cli-launcher.js';
|
import { launcherDefaultTarget } from './utils/cli-launcher.js';
|
||||||
@@ -54,6 +54,8 @@ export interface SpawnBridgeOptions {
|
|||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
|
||||||
|
advisorModel?: string;
|
||||||
sessionName?: string;
|
sessionName?: string;
|
||||||
claudeCliVersion?: string | null;
|
claudeCliVersion?: string | null;
|
||||||
/**
|
/**
|
||||||
@@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
|||||||
engineValues.effortLevel = effortValue;
|
engineValues.effortLevel = effortValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
|
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see
|
||||||
// hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
|
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as
|
||||||
// Code accepts only one `--settings` flag per invocation — rendering them as two independent
|
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation:
|
||||||
// params would let the second one silently win. Claude-only in practice (statusLineCommand
|
// rendering them as independent params would let the last one silently win. Claude-only in
|
||||||
// is resolved claude-mode-only upstream), but this merge is mode-agnostic.
|
// practice: only claude's launch template renders this engine value, so another CLI's
|
||||||
if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
|
// session carrying an advisorModel launches exactly as before.
|
||||||
const settingsObj: Record<string, unknown> =
|
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
|
||||||
effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
|
// byte-identical to one from before the advisor existed.
|
||||||
|
const advisorSettings = buildAdvisorSettings(options.advisorModel);
|
||||||
|
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
|
||||||
|
const settingsObj: Record<string, unknown> = {
|
||||||
|
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
|
||||||
|
...advisorSettings,
|
||||||
|
};
|
||||||
if (options.statusLineCommand) {
|
if (options.statusLineCommand) {
|
||||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||||
}
|
}
|
||||||
|
|||||||
+574
-25
@@ -42,6 +42,7 @@ import {
|
|||||||
NiceConfig,
|
NiceConfig,
|
||||||
DEFAULT_NICE_CONFIG,
|
DEFAULT_NICE_CONFIG,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
isAdvisorModel,
|
||||||
isEffortLevel,
|
isEffortLevel,
|
||||||
type ClaudeMode,
|
type ClaudeMode,
|
||||||
type SessionMode,
|
type SessionMode,
|
||||||
@@ -60,8 +61,11 @@ import {
|
|||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
type SessionNameSource,
|
type SessionNameSource,
|
||||||
type SessionWriteOptions,
|
type SessionWriteOptions,
|
||||||
|
type PaneExit,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||||
|
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||||
|
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
@@ -81,6 +85,9 @@ import {
|
|||||||
trackActivityStreak,
|
trackActivityStreak,
|
||||||
isSustainedActivity,
|
isSustainedActivity,
|
||||||
isPaneQuiet,
|
isPaneQuiet,
|
||||||
|
watchingLabel,
|
||||||
|
isAwaitingWorkers,
|
||||||
|
WATCHING_TAIL_LINES,
|
||||||
IDLE_RECHECK_MS,
|
IDLE_RECHECK_MS,
|
||||||
PANE_PROBE_MIN_INTERVAL_MS,
|
PANE_PROBE_MIN_INTERVAL_MS,
|
||||||
PANE_PROBE_RECHECK_MS,
|
PANE_PROBE_RECHECK_MS,
|
||||||
@@ -206,6 +213,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
|||||||
return getCli(mode)?.capabilities.external ?? true;
|
return getCli(mode)?.capabilities.external ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
|
||||||
|
*
|
||||||
|
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
|
||||||
|
* (claude) launches on that field. Every other CLI takes its model in its own config object
|
||||||
|
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
|
||||||
|
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
|
||||||
|
* publishes and persists the field only where this holds, so a codex cron session never
|
||||||
|
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
|
||||||
|
* any CLI where it does not.
|
||||||
|
*/
|
||||||
|
export function cliTakesSessionModel(mode: SessionMode): boolean {
|
||||||
|
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
|
||||||
|
}
|
||||||
|
|
||||||
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
|
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
|
||||||
function getModeLabel(mode: SessionMode): string {
|
function getModeLabel(mode: SessionMode): string {
|
||||||
return getCli(mode)?.label ?? mode;
|
return getCli(mode)?.label ?? mode;
|
||||||
@@ -497,8 +519,37 @@ export class Session extends EventEmitter {
|
|||||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
||||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
||||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
||||||
|
/**
|
||||||
|
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
|
||||||
|
*
|
||||||
|
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
|
||||||
|
* happened, so it goes stale exactly as that verdict does. The probe returns its
|
||||||
|
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
|
||||||
|
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
|
||||||
|
*
|
||||||
|
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
|
||||||
|
* there, and nothing looks at the pane again until it produces output. That is
|
||||||
|
* correct rather than merely tolerable, because work ending repaints the pane either
|
||||||
|
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
|
||||||
|
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
|
||||||
|
* per idle session per tick to learn nothing.
|
||||||
|
*
|
||||||
|
* A server restart is not a hole in that either, though it looks like one: this field
|
||||||
|
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
|
||||||
|
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
|
||||||
|
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
|
||||||
|
* instance, back within ~20 s for a session whose background terminal was still
|
||||||
|
* running. A session that comes back with no label has no chip on its screen.
|
||||||
|
*/
|
||||||
|
private _watching: string | null = null;
|
||||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||||
private _workingLineRe: RegExp | undefined = undefined;
|
private _workingLineRe: RegExp | undefined = undefined;
|
||||||
|
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||||
|
private _watchingLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
||||||
|
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||||
|
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||||
|
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
@@ -542,6 +593,35 @@ export class Session extends EventEmitter {
|
|||||||
private _mux: TerminalMultiplexer | null = null;
|
private _mux: TerminalMultiplexer | null = null;
|
||||||
private _muxSession: MuxSession | null = null;
|
private _muxSession: MuxSession | null = null;
|
||||||
private _useMux: boolean = false;
|
private _useMux: boolean = false;
|
||||||
|
/**
|
||||||
|
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
|
||||||
|
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
|
||||||
|
* stores for every session shape the field does not apply to. See
|
||||||
|
* {@link PaneExit} for the shapes and for why an unknown answer must never be
|
||||||
|
* rendered as "alive".
|
||||||
|
*/
|
||||||
|
private _paneExit: PaneExit | null = null;
|
||||||
|
/**
|
||||||
|
* How many starts, attaches or relaunches are running for this session's
|
||||||
|
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||||
|
* revived on purpose, so the exited-agent sweep leaves the session alone
|
||||||
|
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
|
||||||
|
* operations cannot clear each other's mark.
|
||||||
|
*/
|
||||||
|
private _paneLifecycleOps = 0;
|
||||||
|
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
|
||||||
|
private _paneStartedAt = 0;
|
||||||
|
/**
|
||||||
|
* The server has started closing this session, so no start or attach may
|
||||||
|
* begin (see {@link markClosing}).
|
||||||
|
*/
|
||||||
|
private _closing = false;
|
||||||
|
/**
|
||||||
|
* This session was rebuilt from the tmux socket rather than from Codeman's
|
||||||
|
* own records, so its `remote`/`docker` metadata is missing rather than known
|
||||||
|
* to be absent. See {@link MuxSession.discovered}.
|
||||||
|
*/
|
||||||
|
private _discoveredMuxSession = false;
|
||||||
// Flag to prevent new timers after session is stopped
|
// Flag to prevent new timers after session is stopped
|
||||||
private _isStopped: boolean = false;
|
private _isStopped: boolean = false;
|
||||||
|
|
||||||
@@ -594,6 +674,11 @@ export class Session extends EventEmitter {
|
|||||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||||
private _effort: EffortLevel | undefined;
|
private _effort: EffortLevel | undefined;
|
||||||
|
|
||||||
|
// Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch
|
||||||
|
// `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused
|
||||||
|
// pairing). A soft default: /advisor still switches or disables it in-session.
|
||||||
|
private _advisorModel: string | undefined;
|
||||||
|
|
||||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
|
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
|
||||||
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
|
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
|
||||||
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
|
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
|
||||||
@@ -710,6 +795,8 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
|
||||||
|
advisorModel?: string;
|
||||||
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
|
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
|
||||||
tmuxHistoryLimit?: number;
|
tmuxHistoryLimit?: number;
|
||||||
/** Restored per-session attachment history. May include server-private external paths. */
|
/** Restored per-session attachment history. May include server-private external paths. */
|
||||||
@@ -718,6 +805,10 @@ export class Session extends EventEmitter {
|
|||||||
lastSubmitAt?: number;
|
lastSubmitAt?: number;
|
||||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||||
claudeSessionChain?: string[];
|
claudeSessionChain?: string[];
|
||||||
|
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||||
|
paneExit?: PaneExit;
|
||||||
|
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||||
|
discoveredMuxSession?: boolean;
|
||||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||||
@@ -866,10 +957,23 @@ export class Session extends EventEmitter {
|
|||||||
if (config.effort && isEffortLevel(config.effort)) {
|
if (config.effort && isEffortLevel(config.effort)) {
|
||||||
this._effort = config.effort;
|
this._effort = config.effort;
|
||||||
}
|
}
|
||||||
|
if (isAdvisorModel(config.advisorModel)) {
|
||||||
|
this._advisorModel = config.advisorModel;
|
||||||
|
}
|
||||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
this._remote = config.remote;
|
this._remote = config.remote;
|
||||||
this._docker = config.docker;
|
this._docker = config.docker;
|
||||||
this._owner = config.owner;
|
this._owner = config.owner;
|
||||||
|
this._discoveredMuxSession = config.discoveredMuxSession === true;
|
||||||
|
// Restored so a record that says the agent exited survives a server restart
|
||||||
|
// rather than being blanked by the first persist after boot. It runs here
|
||||||
|
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
|
||||||
|
// which are set by now. It is a claim about a pane this process has not
|
||||||
|
// looked at yet, so every path that starts or re-attaches a pane drops it
|
||||||
|
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
|
||||||
|
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||||
|
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||||
|
this.setPaneExit(config.paneExit);
|
||||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||||
// both the id and the saved parent come from disk.
|
// both the id and the saved parent come from disk.
|
||||||
@@ -1097,6 +1201,118 @@ export class Session extends EventEmitter {
|
|||||||
return this._muxSession?.muxName ?? null;
|
return this._muxSession?.muxName ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a tmux pane's death would mean THIS session's agent has exited.
|
||||||
|
*
|
||||||
|
* Four shapes fail the test, and each would otherwise publish a death that is
|
||||||
|
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
|
||||||
|
* session's local pane holds the ssh client, whose death means a transport
|
||||||
|
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
|
||||||
|
* local pane holds a `docker exec` into the container's own tmux.
|
||||||
|
*
|
||||||
|
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
|
||||||
|
* normally means "this is local", but on a discovered record it only means
|
||||||
|
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
|
||||||
|
* matches no `state.json` entry, so a remote session rediscovered after
|
||||||
|
* `mux-sessions.json` was lost arrives looking local, and its next transport
|
||||||
|
* drop would be published as an agent exit. Unproven locality fails closed.
|
||||||
|
*/
|
||||||
|
private get paneExitApplies(): boolean {
|
||||||
|
if (this._discoveredMuxSession) return false;
|
||||||
|
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
|
||||||
|
get paneExit(): PaneExit | undefined {
|
||||||
|
return this._paneExit ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while a start, attach or relaunch is running for this session's pane.
|
||||||
|
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
|
||||||
|
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
|
||||||
|
* until it finishes and clears the exit, the pane still reads as dead.
|
||||||
|
*/
|
||||||
|
get paneLifecycleInFlight(): boolean {
|
||||||
|
return this._paneLifecycleOps > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the last start, attach or relaunch of this pane finished, or 0 when
|
||||||
|
* none has run in this process. The exited-agent sweep keeps an exit that
|
||||||
|
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
|
||||||
|
* CLI failing at startup rather than a user ending it. An attach to a pane
|
||||||
|
* that was already running stamps it too, which only costs a user who
|
||||||
|
* `/exit`s within seconds of a server restart a row to close by hand.
|
||||||
|
*/
|
||||||
|
get paneStartedAt(): number {
|
||||||
|
return this._paneStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark this session as being closed, or clear the mark after a close that
|
||||||
|
* failed. While it is set, {@link startInteractive} and {@link startShell}
|
||||||
|
* refuse to run. A start that raced a close would otherwise launch a CLI in a
|
||||||
|
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
|
||||||
|
*/
|
||||||
|
markClosing(closing: boolean): void {
|
||||||
|
this._closing = closing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
|
||||||
|
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
|
||||||
|
this._paneLifecycleOps++;
|
||||||
|
try {
|
||||||
|
return await op();
|
||||||
|
} finally {
|
||||||
|
this._paneLifecycleOps--;
|
||||||
|
this._paneStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget this pane's exit, on both this record and the mux layer's cache.
|
||||||
|
* Every path that starts or relaunches a command in the pane calls it, and
|
||||||
|
* the mux half also invalidates a pane read already in flight.
|
||||||
|
*
|
||||||
|
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
|
||||||
|
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
|
||||||
|
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
|
||||||
|
* reports no change and therefore writes nothing, so a caller that only
|
||||||
|
* broadcasts leaves `state.json` saying the agent exited for as long as the
|
||||||
|
* session stays quiet. `/interactive` and `/shell` did exactly that until
|
||||||
|
* Ark0N/Codeman#446 review; both now persist on their success path.
|
||||||
|
*/
|
||||||
|
private clearPaneExitForNewPane(): void {
|
||||||
|
this.setPaneExit(undefined);
|
||||||
|
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record what the mux layer observed of this pane's agent, and say whether
|
||||||
|
* that changed the answer. The caller persists and broadcasts on a true.
|
||||||
|
*
|
||||||
|
* A session the field does not apply to is forced to UNKNOWN here rather than
|
||||||
|
* at the reporting end, so the rule lives in one place and the mux layer stays
|
||||||
|
* free to report the raw pane reading its own remote-reconnect watcher needs.
|
||||||
|
*/
|
||||||
|
setPaneExit(next: PaneExit | undefined): boolean {
|
||||||
|
const resolved = this.paneExitApplies ? (next ?? null) : null;
|
||||||
|
const prev = this._paneExit;
|
||||||
|
if (prev === resolved) return false;
|
||||||
|
if (
|
||||||
|
prev !== null &&
|
||||||
|
resolved !== null &&
|
||||||
|
prev.status === resolved.status &&
|
||||||
|
prev.signal === resolved.signal &&
|
||||||
|
prev.at === resolved.at
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._paneExit = resolved;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this session's PTY is a tmux client rather than the program itself.
|
* True when this session's PTY is a tmux client rather than the program itself.
|
||||||
* Read by the replay-side alt-screen strip, which must apply the same
|
* Read by the replay-side alt-screen strip, which must apply the same
|
||||||
@@ -1118,6 +1334,16 @@ export class Session extends EventEmitter {
|
|||||||
return this._isWorking;
|
return this._isWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
|
||||||
|
* nothing is. A session with a label here has ended its turn without wanting anything
|
||||||
|
* from the user, so a surface that would otherwise file it under "needs you" can say
|
||||||
|
* what it is waiting for instead.
|
||||||
|
*/
|
||||||
|
get watching(): string | null {
|
||||||
|
return this._watching;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||||
@@ -1421,6 +1647,19 @@ export class Session extends EventEmitter {
|
|||||||
return this._nameSource;
|
return this._nameSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude
|
||||||
|
* title the conversation itself. `--name` is the prompt-box label, the
|
||||||
|
* `/resume` picker entry and the terminal title all at once, and a pinned
|
||||||
|
* title stops Claude generating its own, so only a name the user chose is
|
||||||
|
* worth pinning. Pinning the `w1-myapp` placeholder gave every conversation
|
||||||
|
* in a case the same `/resume` entry; an auto name is a cut of the first
|
||||||
|
* prompt, which Claude's own generated title already beats.
|
||||||
|
*/
|
||||||
|
get cliPinnedName(): string | undefined {
|
||||||
|
return this._nameSource === 'manual' ? this._name : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||||
this._autoOps.setAutoClear(enabled, threshold);
|
this._autoOps.setAutoClear(enabled, threshold);
|
||||||
}
|
}
|
||||||
@@ -1614,12 +1853,20 @@ export class Session extends EventEmitter {
|
|||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
|
||||||
|
// object) and may be the app-wide Claude default cron handed it.
|
||||||
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
|
advisorModel: this._advisorModel,
|
||||||
customModel: this.customModel,
|
customModel: this.customModel,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||||
// recovery can re-attach.
|
// recovery can re-attach.
|
||||||
respawnBlocked: this._respawnBlocked || undefined,
|
respawnBlocked: this._respawnBlocked || undefined,
|
||||||
|
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
|
||||||
|
// it rides the existing `session:updated` broadcast and lands in state.json
|
||||||
|
// through the same persist. `status` and `pid` above stay untouched by it.
|
||||||
|
paneExit: this._paneExit ?? undefined,
|
||||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||||
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
||||||
@@ -1675,6 +1922,7 @@ export class Session extends EventEmitter {
|
|||||||
totalCost: this._totalCost,
|
totalCost: this._totalCost,
|
||||||
messageCount: this._messages.length,
|
messageCount: this._messages.length,
|
||||||
isWorking: this._isWorking,
|
isWorking: this._isWorking,
|
||||||
|
watching: this._watching,
|
||||||
lastPromptTime: this._lastPromptTime,
|
lastPromptTime: this._lastPromptTime,
|
||||||
// Buffer statistics for monitoring long-running sessions
|
// Buffer statistics for monitoring long-running sessions
|
||||||
bufferStats: {
|
bufferStats: {
|
||||||
@@ -1731,41 +1979,90 @@ export class Session extends EventEmitter {
|
|||||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||||
spawnErrLabel: string;
|
spawnErrLabel: string;
|
||||||
}): Promise<{ isRestored: boolean }> {
|
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||||
|
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doSetupOrAttachMuxSession(options: {
|
||||||
|
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||||
|
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||||
|
spawnErrLabel: string;
|
||||||
|
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||||
const mux = this._mux!;
|
const mux = this._mux!;
|
||||||
|
|
||||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally).
|
||||||
|
// A session that HAD a mux session relaunches its CLI below just like a failed
|
||||||
|
// respawn does (tmux kill-server, a tmux crash, an external kill-session), so
|
||||||
|
// its transcript collides with the bare `--session-id` the same way. A
|
||||||
|
// genuinely new session starts with `_muxSession` null and never sets this.
|
||||||
|
let muxSessionVanished = false;
|
||||||
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||||
this._muxSession = null;
|
this._muxSession = null;
|
||||||
|
muxSessionVanished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||||
let needsNewSession = false;
|
let needsNewSession = false;
|
||||||
|
let respawnedDeadPane = false;
|
||||||
|
let respawnedResumeId: string | undefined;
|
||||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
||||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||||
this._pinOmpRespawnId();
|
this._pinOmpRespawnId();
|
||||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
const respawnOptions = await this._buildRespawnPaneOptionsWithResumePin();
|
||||||
|
const newPid = await mux.respawnPane(respawnOptions);
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
|
respawnedDeadPane = true;
|
||||||
|
respawnedResumeId = respawnOptions.resumeSessionId;
|
||||||
this._pendingEnvUnsets.clear();
|
this._pendingEnvUnsets.clear();
|
||||||
// Wait a moment for the respawned process to fully start
|
// Wait a moment for the respawned process to fully start
|
||||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whatever the last reading said about the OLD command in this pane is now
|
||||||
|
// history: the branch above either respawned the pane or found it alive, and
|
||||||
|
// the branch below creates a new one. The paths that reach here are boot
|
||||||
|
// recovery and an explicit start, NOT a click on an exited tab — the browser
|
||||||
|
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
|
||||||
|
// that an exited pane keeps its pid. `restartCli()` clears separately.
|
||||||
|
this.clearPaneExitForNewPane();
|
||||||
|
|
||||||
// Check if we already have a mux session (restored session)
|
// Check if we already have a mux session (restored session)
|
||||||
const isRestored = this._muxSession !== null && !needsNewSession;
|
const isRestored = this._muxSession !== null && !needsNewSession;
|
||||||
if (isRestored) {
|
if (isRestored) {
|
||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session. When this is the FALLBACK after a failed
|
||||||
|
// respawn, the eagerly-built create options still carry the unpinned
|
||||||
|
// launch seed, so a session whose transcript exists would meet the same
|
||||||
|
// `--session-id ... already in use` refusal the respawn just lost to —
|
||||||
|
// the recovery of last resort failing for the very reason it was needed.
|
||||||
|
// A genuinely new session has no transcript under any of its candidate
|
||||||
|
// ids, so nothing is pinned and its command shape is unchanged.
|
||||||
|
//
|
||||||
|
// `_resumeSessionId` is written alongside, not just the create options:
|
||||||
|
// this branch leaves `isRestored` false, so the block that sets
|
||||||
|
// `_claudeSessionId` below reads that field and would otherwise settle on
|
||||||
|
// `this.id` while the CLI resumes the chain tail. The response viewer,
|
||||||
|
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
|
||||||
|
// until the next first-hand hook, so the two have to name the same
|
||||||
|
// conversation. The vanished-tmux-session branch above relaunches for the
|
||||||
|
// same reason and takes the same pin.
|
||||||
|
if (needsNewSession || muxSessionVanished) {
|
||||||
|
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
|
||||||
|
if (pinned) {
|
||||||
|
options.createSessionOptions.resumeSessionId = pinned;
|
||||||
|
this._resumeSessionId = pinned;
|
||||||
|
}
|
||||||
|
}
|
||||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
// No extra sleep — createSession() already waits for tmux readiness
|
// No extra sleep — createSession() already waits for tmux readiness
|
||||||
@@ -1800,13 +2097,14 @@ export class Session extends EventEmitter {
|
|||||||
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(ptyCols, ptyRows);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||||
throw spawnErr;
|
throw spawnErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isRestored };
|
return { isRestored, respawnedResumeId, respawnedDeadPane };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1844,6 +2142,9 @@ export class Session extends EventEmitter {
|
|||||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// No-op for the record (a remote session's field is always UNKNOWN), but the
|
||||||
|
// mux layer's cache is keyed by muxName and this pane now runs a new client.
|
||||||
|
this.clearPaneExitForNewPane();
|
||||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1869,6 +2170,10 @@ export class Session extends EventEmitter {
|
|||||||
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
||||||
*/
|
*/
|
||||||
async restartCli(): Promise<boolean> {
|
async restartCli(): Promise<boolean> {
|
||||||
|
return this._withPaneLifecycle(() => this._doRestartCli());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doRestartCli(): Promise<boolean> {
|
||||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||||
const mux = this._mux;
|
const mux = this._mux;
|
||||||
|
|
||||||
@@ -1878,26 +2183,16 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._pinOmpRespawnId();
|
this._pinOmpRespawnId();
|
||||||
const options = this._buildRespawnPaneOptions();
|
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
||||||
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
|
||||||
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
|
||||||
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
|
||||||
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
|
||||||
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
|
||||||
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
|
||||||
// already use, so pin the live conversation id for THIS respawn only. The registry
|
|
||||||
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
|
||||||
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
|
||||||
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
|
||||||
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
|
||||||
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
|
||||||
}
|
|
||||||
const newPid = await mux.respawnPane(options);
|
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._pendingEnvUnsets.clear();
|
this._pendingEnvUnsets.clear();
|
||||||
|
// A relaunch in the same pane, so any exit observed of the previous command
|
||||||
|
// is history. Without this the caller's persist-and-broadcast writes the old
|
||||||
|
// exit straight back onto a session that is running again.
|
||||||
|
this.clearPaneExitForNewPane();
|
||||||
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1914,6 +2209,7 @@ export class Session extends EventEmitter {
|
|||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
name: this._name,
|
name: this._name,
|
||||||
|
cliName: this.cliPinnedName,
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
model: this._model,
|
model: this._model,
|
||||||
claudeMode: this._claudeMode,
|
claudeMode: this._claudeMode,
|
||||||
@@ -1939,6 +2235,7 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
advisorModel: this._advisorModel,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
@@ -1947,6 +2244,112 @@ export class Session extends EventEmitter {
|
|||||||
return this._withCustomModelLaunchModel(options);
|
return this._withCustomModelLaunchModel(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respawn options for a pane whose command is being REPLACED, with the
|
||||||
|
* conversation pinned so the relaunch resumes rather than collides.
|
||||||
|
*
|
||||||
|
* A CLI that launches with `--session-id <id>` refuses an id that is already
|
||||||
|
* in use (claude: `Error: Session ID ... is already in use.`), and every
|
||||||
|
* session whose agent has been prompted owns a transcript under that id. So
|
||||||
|
* relaunching such a pane with the bare launch line fails, the pane dies
|
||||||
|
* again immediately, and the user's conversation is stranded. A launch that
|
||||||
|
* declares a `fallback` chain renders `resume || new` once a resume id is
|
||||||
|
* set, which is the shape that survives both cases.
|
||||||
|
*
|
||||||
|
* Three candidates are tried in priority order — the conversation chain's
|
||||||
|
* tail, the launch seed, then the session's own id — and the first one a
|
||||||
|
* transcript backs is pinned. Four conditions gate that walk, each protecting
|
||||||
|
* against a way of resuming the WRONG conversation or of making a working
|
||||||
|
* relaunch fail.
|
||||||
|
*
|
||||||
|
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
|
||||||
|
* whose route refuses both, the dead-pane respawn is reached by every session
|
||||||
|
* shape. Their pane commands (`buildRemoteLaunchCommand`,
|
||||||
|
* `claudeDockerPaneCommand`) already render a SELF-HEALING
|
||||||
|
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
|
||||||
|
* moment the resume id differs from the session id. The conversation lives on
|
||||||
|
* the far side, so a local id pinned onto it resolves to nothing there, the
|
||||||
|
* resume fails, and the `--session-id` fallback then collides with the
|
||||||
|
* transcript the far side really does hold — both branches fail and the pane
|
||||||
|
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
|
||||||
|
*
|
||||||
|
* ⚠️ **The candidates come from the conversation CHAIN, never from
|
||||||
|
* `_claudeSessionId`.** That field holds either a first-hand id from the
|
||||||
|
* CLI's own hook payload or a history correlation, which is a guess keyed on
|
||||||
|
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
|
||||||
|
* precisely so it cannot "write a foreign conversation into this pane's
|
||||||
|
* permanent record", and launching from one would do worse than the display
|
||||||
|
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
|
||||||
|
* a conversation that was never this pane's. The chain's tail is the live
|
||||||
|
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
|
||||||
|
* seed, which is written once at construction and never moves off a `/clear`.
|
||||||
|
*
|
||||||
|
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
|
||||||
|
* included, and a candidate that has none is passed over rather than ending
|
||||||
|
* the walk.** A pin that differs from the session id leaves
|
||||||
|
* `--session-id <this.id>` in the fallback branch, so a resume that finds
|
||||||
|
* nothing collides there and the pane dies exactly as it did before this
|
||||||
|
* pinning existed. Pinning `this.id` renders the self-healing
|
||||||
|
* `--resume <id> || --session-id <id>`, which is correct whether or not a
|
||||||
|
* transcript exists, but a pane that has none pays for the shape twice:
|
||||||
|
* claude prints "No conversation found" into the scrollback of a session that
|
||||||
|
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
|
||||||
|
* rendered `a || b`, so the branch that actually runs loses its priority for
|
||||||
|
* the life of the session. Falling off the end of the walk therefore adds
|
||||||
|
* no pin (the options keep any launch seed they already carried), which is
|
||||||
|
* the right answer: with no transcript anywhere there is nothing for the
|
||||||
|
* bare `--session-id <this.id>` to collide with.
|
||||||
|
*
|
||||||
|
* The create route pre-validates a resume id for the same reason, though it
|
||||||
|
* additionally requires the transcript be substantial — here mere existence
|
||||||
|
* is the question, because a one-line transcript still makes `--session-id`
|
||||||
|
* collide.
|
||||||
|
*
|
||||||
|
* The registry shape is the last gate, not the CLI's name: an entry whose
|
||||||
|
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
|
||||||
|
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
|
||||||
|
*
|
||||||
|
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
|
||||||
|
* session command, which attaches to the durable remote tmux with the agent
|
||||||
|
* still inside it and renders no local `--session-id` to collide.
|
||||||
|
*/
|
||||||
|
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
|
||||||
|
const options = this._buildRespawnPaneOptions();
|
||||||
|
if (this._remote || this._docker) return options;
|
||||||
|
const entry = getCli(this.mode);
|
||||||
|
if (entry?.launch.chain !== 'fallback') return options;
|
||||||
|
|
||||||
|
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||||
|
const configDir = this._claudeConfigDir();
|
||||||
|
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||||
|
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// A session Codeman DISCOVERED on the socket rather than created carries a
|
||||||
|
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
|
||||||
|
// pattern. The renderer would silently drop the resume flag and emit the
|
||||||
|
// unpinned command, so say so here rather than letting the caller believe
|
||||||
|
// the pane was pinned.
|
||||||
|
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
|
||||||
|
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
|
||||||
|
options.resumeSessionId = candidate;
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
|
||||||
|
// unpinned options already carry is the correct command.
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||||
|
private _claudeConfigDir(): string | undefined {
|
||||||
|
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||||
|
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||||
|
// otherwise resolve to a relative path and read "no transcript" for everything.
|
||||||
|
return this._envOverrides?.CLAUDE_CONFIG_DIR?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||||
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||||
@@ -2160,6 +2563,9 @@ export class Session extends EventEmitter {
|
|||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
throw new Error('Session already has a running process');
|
throw new Error('Session already has a running process');
|
||||||
}
|
}
|
||||||
|
if (this._closing) {
|
||||||
|
throw new Error('Session is being closed');
|
||||||
|
}
|
||||||
|
|
||||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
||||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
||||||
@@ -2268,7 +2674,7 @@ export class Session extends EventEmitter {
|
|||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
try {
|
try {
|
||||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
|
||||||
// Single source of truth shared with reattachRemote() (COD-108).
|
// Single source of truth shared with reattachRemote() (COD-108).
|
||||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
@@ -2276,6 +2682,7 @@ export class Session extends EventEmitter {
|
|||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
name: this._name,
|
name: this._name,
|
||||||
|
cliName: this.cliPinnedName,
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
model: this._model,
|
model: this._model,
|
||||||
claudeMode: this._claudeMode,
|
claudeMode: this._claudeMode,
|
||||||
@@ -2291,6 +2698,7 @@ export class Session extends EventEmitter {
|
|||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
advisorModel: this._advisorModel,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
remote: this._remote,
|
remote: this._remote,
|
||||||
docker: this._docker,
|
docker: this._docker,
|
||||||
@@ -2315,7 +2723,18 @@ export class Session extends EventEmitter {
|
|||||||
// persisted chain's tail is that conversation, reported first-hand by
|
// persisted chain's tail is that conversation, reported first-hand by
|
||||||
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
||||||
// an empty chain and falls through to the resume/alias fallbacks.
|
// an empty chain and falls through to the resume/alias fallbacks.
|
||||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
//
|
||||||
|
// A dead-pane respawn is NOT that case for a CLI whose relaunch the resume
|
||||||
|
// pin walk governs (`launch.chain === 'fallback'`): the CLI did stop, and
|
||||||
|
// the walk may have passed over a chain tail with no transcript behind it,
|
||||||
|
// so the conversation is whatever the respawn actually resumed. Undefined
|
||||||
|
// there means the pane launched unpinned, which the fallbacks below name.
|
||||||
|
const pinGovernsRespawn = respawnedDeadPane && getCli(this.mode)?.launch.chain === 'fallback';
|
||||||
|
restoredConversation = pinGovernsRespawn
|
||||||
|
? respawnedResumeId
|
||||||
|
: isRestored
|
||||||
|
? this._claudeSessionChain[this._claudeSessionChain.length - 1]
|
||||||
|
: undefined;
|
||||||
this._claudeSessionId =
|
this._claudeSessionId =
|
||||||
restoredConversation ||
|
restoredConversation ||
|
||||||
this._resumeSessionId ||
|
this._resumeSessionId ||
|
||||||
@@ -2403,8 +2822,9 @@ export class Session extends EventEmitter {
|
|||||||
this._model,
|
this._model,
|
||||||
this._allowedTools,
|
this._allowedTools,
|
||||||
this._effort,
|
this._effort,
|
||||||
this._name,
|
this.cliPinnedName,
|
||||||
getClaudeCliVersion()
|
getClaudeCliVersion(),
|
||||||
|
this._advisorModel
|
||||||
);
|
);
|
||||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||||
pty.spawn(getClaudeBinaryPath(), args, {
|
pty.spawn(getClaudeBinaryPath(), args, {
|
||||||
@@ -2416,6 +2836,7 @@ export class Session extends EventEmitter {
|
|||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -2708,10 +3129,82 @@ export class Session extends EventEmitter {
|
|||||||
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
||||||
this._lastPaneProbeAt = now;
|
this._lastPaneProbeAt = now;
|
||||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(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);
|
||||||
|
this._readWatching(text);
|
||||||
return this._lastPaneProbeWorking;
|
return this._lastPaneProbeWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the background-work chip off the same capture the working probe just took.
|
||||||
|
*
|
||||||
|
* The two questions are different. A turn that is running is work the user is waiting
|
||||||
|
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
|
||||||
|
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
|
||||||
|
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
|
||||||
|
* capture at exactly the moment the turn ends, which is the moment the answer starts
|
||||||
|
* mattering.
|
||||||
|
*
|
||||||
|
* A capture that could not be read CLEARS the label rather than keeping the last one.
|
||||||
|
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
|
||||||
|
* already acknowledged, so a failed capture would silence a real alert, while a dropped
|
||||||
|
* label only costs a card and an alert that the next readable capture takes back.
|
||||||
|
* Degrading toward the alert is the rule the whole signal is built on.
|
||||||
|
*/
|
||||||
|
private _readWatching(paneText: string | null): void {
|
||||||
|
const pattern = this._watchingLinePattern();
|
||||||
|
if (!pattern) return;
|
||||||
|
// `null` is "the screen could not be read". That is no evidence either way, so the
|
||||||
|
// label falls to null (and the change is announced below like any other).
|
||||||
|
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
|
||||||
|
if (label === this._watching) return;
|
||||||
|
this._watching = label;
|
||||||
|
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
|
||||||
|
// own. The label is usually set on the idle transition, which broadcasts anyway, but
|
||||||
|
// it CLEARS when the work ends — and for a CLI whose background work ends without
|
||||||
|
// taking a turn (measured on codex: a background terminal finishing repaints the row
|
||||||
|
// away and nothing else happens) the session is idle before and after. Without this,
|
||||||
|
// the server knew the badge was gone and every open page went on drawing it until
|
||||||
|
// some unrelated event arrived.
|
||||||
|
this.emit('watchingChanged');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
|
||||||
|
* entry declares none. Compiled once per session, like the working-line pattern, and
|
||||||
|
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
|
||||||
|
* guessing one would badge sessions on the strength of an unread screen.
|
||||||
|
*/
|
||||||
|
private _watchingLinePattern(): RegExp | null {
|
||||||
|
if (this._watchingLineRe === undefined) {
|
||||||
|
// The pattern and the window it runs over are one decision, so they are resolved
|
||||||
|
// together: how far up the screen a CLI's row can sit is as much a property of its
|
||||||
|
// layout as the row itself. Claude writes on the last row and keeps the default,
|
||||||
|
// Codex pins one above its composer and declares more.
|
||||||
|
const detect = getCli(this.mode)?.capabilities.workDetect;
|
||||||
|
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
|
||||||
|
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
|
||||||
|
}
|
||||||
|
return this._watchingLineRe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the newest turn on this screen ended waiting for workers the CLI started
|
||||||
|
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
|
||||||
|
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
|
||||||
|
*/
|
||||||
|
private _paneAwaitsWorkers(paneText: string): boolean {
|
||||||
|
if (this._awaitingLineRe === undefined) {
|
||||||
|
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
|
||||||
|
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
|
||||||
|
}
|
||||||
|
if (!this._awaitingLineRe) return false;
|
||||||
|
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
|
||||||
|
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regex matching this CLI's "a turn is running" status line.
|
* The regex matching this CLI's "a turn is running" status line.
|
||||||
*
|
*
|
||||||
@@ -2914,6 +3407,9 @@ export class Session extends EventEmitter {
|
|||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
throw new Error('Session already has a running process');
|
throw new Error('Session already has a running process');
|
||||||
}
|
}
|
||||||
|
if (this._closing) {
|
||||||
|
throw new Error('Session is being closed');
|
||||||
|
}
|
||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
@@ -2984,6 +3480,7 @@ export class Session extends EventEmitter {
|
|||||||
env: buildShellEnv(this.id),
|
env: buildShellEnv(this.id),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -3093,6 +3590,7 @@ export class Session extends EventEmitter {
|
|||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this._notePtySpawnGeometry(120, 40);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
this.emit(
|
this.emit(
|
||||||
@@ -3742,6 +4240,16 @@ export class Session extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the composer check for a prompt that went out some other way than
|
||||||
|
* `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter
|
||||||
|
* separately. `text` is what the composer line starts with while the prompt is still
|
||||||
|
* unsent; the check re-presses Enter only while that holds.
|
||||||
|
*/
|
||||||
|
verifySubmitted(text: string): void {
|
||||||
|
this._verifySubmitted(`${text}\r`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
||||||
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||||
@@ -3779,6 +4287,40 @@ export class Session extends EventEmitter {
|
|||||||
private _ptyCols = 120;
|
private _ptyCols = 120;
|
||||||
private _ptyRows = 40;
|
private _ptyRows = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
|
||||||
|
* tmux window's size, not the constructor's 120x40, and without this
|
||||||
|
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
|
||||||
|
* `resize()` skipped a real resize that happened to match them.
|
||||||
|
*/
|
||||||
|
private _notePtySpawnGeometry(cols: number, rows: number): void {
|
||||||
|
this._ptyCols = cols;
|
||||||
|
this._ptyRows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The geometry the CLI is actually drawing for, or null when nothing is
|
||||||
|
* drawing.
|
||||||
|
*
|
||||||
|
* Exposed because `resize()` can decline a request outright (arbitration
|
||||||
|
* below) and the asking client has no other way to find out: a browser
|
||||||
|
* terminal that keeps a shape the PTY refused renders garbled output rather
|
||||||
|
* than wrong-sized output, because Claude Code's repaints are computed from
|
||||||
|
* the width it was told (issue #464). Both transports report this back.
|
||||||
|
*
|
||||||
|
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
|
||||||
|
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
|
||||||
|
* a dead pane (or one created through the API and never started) still
|
||||||
|
* holds the constructor defaults of 120x40, or the size of a pane that is
|
||||||
|
* gone. Reporting those made a client adopt a size no process had ever
|
||||||
|
* been told, and on anything narrower than 120 columns it claimed another
|
||||||
|
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
||||||
|
* report with no finite numbers as no evidence, which is the truth here.
|
||||||
|
*/
|
||||||
|
get ptyGeometry(): { cols: number; rows: number } | null {
|
||||||
|
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live WebSocket connections that have announced a desktop viewport for this
|
* Live WebSocket connections that have announced a desktop viewport for this
|
||||||
* session. While at least one is registered, small-viewport (mobile/tablet)
|
* session. While at least one is registered, small-viewport (mobile/tablet)
|
||||||
@@ -3864,6 +4406,10 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
||||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||||
|
// Declined. The caller is told nothing here on purpose — the decision
|
||||||
|
// belongs to the session, not the socket — but the caller MUST report
|
||||||
|
// `ptyGeometry` back afterwards so the asking client can adopt
|
||||||
|
// the shape it did not get. Both transports do; see issue #464.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._mobileSizeOverride = true;
|
this._mobileSizeOverride = true;
|
||||||
@@ -3963,6 +4509,9 @@ export class Session extends EventEmitter {
|
|||||||
async stop(killMux: boolean = true): Promise<void> {
|
async stop(killMux: boolean = true): Promise<void> {
|
||||||
// Set stopped flag first to prevent new timers from being created
|
// Set stopped flag first to prevent new timers from being created
|
||||||
this._isStopped = true;
|
this._isStopped = true;
|
||||||
|
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
|
||||||
|
// without this the last chip it drew would ride along on its row forever.
|
||||||
|
this._watching = null;
|
||||||
|
|
||||||
this._clearAllTimers();
|
this._clearAllTimers();
|
||||||
|
|
||||||
|
|||||||
+401
-18
@@ -58,6 +58,7 @@ import {
|
|||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
type DockerCommandMode,
|
type DockerCommandMode,
|
||||||
|
type PaneExit,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getCli } from './config/cli-registry/registry.js';
|
import { getCli } from './config/cli-registry/registry.js';
|
||||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||||
@@ -152,6 +153,16 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
|||||||
/** Default stats collection interval (2 seconds) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the pane-exit watcher re-reads every pane on the socket. The
|
||||||
|
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
|
||||||
|
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
|
||||||
|
* Matched to the stats cadence above because both cost one batched tmux read.
|
||||||
|
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
|
||||||
|
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
|
||||||
|
*/
|
||||||
|
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||||
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
|||||||
*/
|
*/
|
||||||
const PANE_LIST_SEP = '|';
|
const PANE_LIST_SEP = '|';
|
||||||
|
|
||||||
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
|
/**
|
||||||
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
|
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
|
||||||
|
*
|
||||||
|
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
|
||||||
|
* Appending them is backward compatible in both directions. A tmux that does not
|
||||||
|
* know a variable substitutes the empty string rather than failing, which is how
|
||||||
|
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
|
||||||
|
* short row as "pid known, deadness unknown" rather than discarding it.
|
||||||
|
*/
|
||||||
|
const PANE_LIST_FORMAT =
|
||||||
|
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
|
||||||
|
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 pane 启动前的 nofile 修复命令。
|
* 构建 pane 启动前的 nofile 修复命令。
|
||||||
@@ -235,26 +256,154 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
|
|||||||
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
|
||||||
|
export interface PaneRow {
|
||||||
|
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
|
||||||
|
sessionName: string;
|
||||||
|
/** `#{pane_pid}` — the process tmux started in the pane. */
|
||||||
|
pid: number;
|
||||||
|
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
|
||||||
|
dead?: boolean;
|
||||||
|
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
|
||||||
|
exitStatus?: number;
|
||||||
|
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
|
||||||
|
exitSignal?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
|
* One pane-exit reading, with the pane pid that produced it.
|
||||||
* into a Map of session-name → pane pid. Exported for unit testing.
|
*
|
||||||
|
* The pid never leaves this module. It is what distinguishes "the same dead
|
||||||
|
* pane, seen again" from "a second command in the same pane that also exited
|
||||||
|
* with the same status", so the `at` stamp can hold across the first and must
|
||||||
|
* not across the second. {@link PaneExit} itself stays free of it: the pid on
|
||||||
|
* the session record is the attach client's, and a second pid there would
|
||||||
|
* invite exactly the confusion Ark0N/Codeman#446 is about.
|
||||||
|
*/
|
||||||
|
export interface PaneExitObservation {
|
||||||
|
/** `#{pane_pid}` of the pane this reading came from. */
|
||||||
|
panePid: number;
|
||||||
|
/** What to publish on the session record. */
|
||||||
|
exit: PaneExit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link PaneExitObservation} as the manager stores it, with a count of the
|
||||||
|
* authoritative reads that have seen this same exit. The count is what lets
|
||||||
|
* the exited-agent sweep act only on a death that more than one read agreed on
|
||||||
|
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
|
||||||
|
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
|
||||||
|
* the count nor resets it.
|
||||||
|
*/
|
||||||
|
interface TrackedPaneExit extends PaneExitObservation {
|
||||||
|
/** Authoritative reads that saw this exit, counting the first. */
|
||||||
|
reads: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
||||||
|
function paneField(fields: string[], index: number): number | undefined {
|
||||||
|
const raw = fields[index];
|
||||||
|
if (raw === undefined || raw === '') return undefined;
|
||||||
|
const value = parseInt(raw, 10);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
|
||||||
|
* into one row per pane, in tmux's own order. Exported for unit testing.
|
||||||
*
|
*
|
||||||
* - Skips empty lines and lines without the separator.
|
* - Skips empty lines and lines without the separator.
|
||||||
* - Skips entries with a non-numeric pid or empty name.
|
* - Skips entries with a non-numeric pid or empty name.
|
||||||
|
* - Leaves every field after the pid undefined when it is blank or absent, so a
|
||||||
|
* row from an older tmux still yields its pid.
|
||||||
*/
|
*/
|
||||||
export function parsePaneList(output: string): Map<string, number> {
|
export function parsePaneRows(output: string): PaneRow[] {
|
||||||
const result = new Map<string, number>();
|
const rows: PaneRow[] = [];
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const sep = line.indexOf(PANE_LIST_SEP);
|
if (!line.includes(PANE_LIST_SEP)) continue;
|
||||||
if (sep === -1) continue;
|
const fields = line.split(PANE_LIST_SEP);
|
||||||
const name = line.slice(0, sep);
|
const sessionName = fields[0];
|
||||||
const pid = parseInt(line.slice(sep + 1), 10);
|
const pid = parseInt(fields[1] ?? '', 10);
|
||||||
if (name && !Number.isNaN(pid)) {
|
if (!sessionName || Number.isNaN(pid)) continue;
|
||||||
result.set(name, pid);
|
const deadFlag = fields[2];
|
||||||
|
rows.push({
|
||||||
|
sessionName,
|
||||||
|
pid,
|
||||||
|
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
|
||||||
|
exitStatus: paneField(fields, 3),
|
||||||
|
exitSignal: paneField(fields, 4),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
/**
|
||||||
|
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
|
||||||
|
* Exported for unit testing. Returns one entry per session with a known answer;
|
||||||
|
* a session absent from the map is UNKNOWN, which must never render as alive.
|
||||||
|
*
|
||||||
|
* Two rules make a positive answer trustworthy:
|
||||||
|
*
|
||||||
|
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
|
||||||
|
* creates one pane per session and `isPaneDead()` reads one pane, so a session
|
||||||
|
* the user has split by hand has no single "the agent" to report on, and
|
||||||
|
* guessing which of its panes speaks for the session could report a live
|
||||||
|
* session as exited.
|
||||||
|
*
|
||||||
|
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
|
||||||
|
* that did not answer, not a live pane.
|
||||||
|
*
|
||||||
|
* `status` and `signal` stay absent when tmux did not report them. Measured on
|
||||||
|
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
|
||||||
|
* 0 would turn an unexplained death into a clean exit.
|
||||||
|
*/
|
||||||
|
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
|
||||||
|
const panesPerSession = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const exits = new Map<string, PaneExitObservation>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||||
|
if (row.dead !== true) continue;
|
||||||
|
exits.set(row.sessionName, {
|
||||||
|
panePid: row.pid,
|
||||||
|
exit: {
|
||||||
|
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
|
||||||
|
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
|
||||||
|
at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return exits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||||
|
* for unit testing.
|
||||||
|
*
|
||||||
|
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
|
||||||
|
* remote session's local pane holds the ssh client, a docker case's holds a
|
||||||
|
* `docker exec` into the container's own tmux, and a record rebuilt from the
|
||||||
|
* socket carries no provenance at all, so the session end forces all three to
|
||||||
|
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
|
||||||
|
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
|
||||||
|
* the answer.
|
||||||
|
*
|
||||||
|
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
|
||||||
|
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
|
||||||
|
* with nothing to observe and picks the read straight back up as soon as one
|
||||||
|
* local session exists.
|
||||||
|
*/
|
||||||
|
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.remote) continue;
|
||||||
|
if (session.docker) continue;
|
||||||
|
if (session.discovered === true) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -733,9 +882,11 @@ export function buildSpawnCommand(options: {
|
|||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
|
||||||
|
advisorModel?: string;
|
||||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||||
statusLineCommand?: string;
|
statusLineCommand?: string;
|
||||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
|
||||||
sessionName?: string;
|
sessionName?: string;
|
||||||
/**
|
/**
|
||||||
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
||||||
@@ -1527,6 +1678,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||||
private lastPaneCount: Map<string, number> = new Map();
|
private lastPaneCount: Map<string, number> = new Map();
|
||||||
|
/**
|
||||||
|
* muxName → the exited agent the pane-exit watcher last observed
|
||||||
|
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
|
||||||
|
* entry goes the moment tmux stops reporting the pane dead, and the map is
|
||||||
|
* empty until the first read runs. The manager reports what tmux says and
|
||||||
|
* nothing more: the scoping that hides this for remote and docker sessions
|
||||||
|
* lives on `Session`, because the remote-reconnect watcher above needs the
|
||||||
|
* raw pane reading.
|
||||||
|
*/
|
||||||
|
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
||||||
|
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||||
|
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||||
|
/**
|
||||||
|
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
|
||||||
|
* poll interval of 2000 ms, so without this a slow read overlaps the next two
|
||||||
|
* and the older one can resolve last and win.
|
||||||
|
*/
|
||||||
|
private paneExitReadInFlight = false;
|
||||||
|
/**
|
||||||
|
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
|
||||||
|
* a clear carries the older generation and is discarded rather than writing
|
||||||
|
* the death back over the pane that has just replaced it.
|
||||||
|
*/
|
||||||
|
private paneExitGeneration = 0;
|
||||||
|
|
||||||
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
||||||
/** Periodic watcher that re-establishes dropped remote sessions. */
|
/** Periodic watcher that re-establishes dropped remote sessions. */
|
||||||
@@ -1894,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
workingDir,
|
workingDir,
|
||||||
mode,
|
mode,
|
||||||
name,
|
name,
|
||||||
|
cliName,
|
||||||
niceConfig,
|
niceConfig,
|
||||||
model,
|
model,
|
||||||
claudeMode,
|
claudeMode,
|
||||||
@@ -1909,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
advisorModel,
|
||||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
@@ -1996,8 +2173,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
|
advisorModel,
|
||||||
statusLineCommand,
|
statusLineCommand,
|
||||||
sessionName: name,
|
sessionName: cliName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
@@ -2223,9 +2401,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
envOverrides,
|
envOverrides,
|
||||||
unsetEnvKeys,
|
unsetEnvKeys,
|
||||||
effort,
|
effort,
|
||||||
|
advisorModel,
|
||||||
remote,
|
remote,
|
||||||
docker,
|
docker,
|
||||||
name,
|
cliName,
|
||||||
} = options;
|
} = options;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
@@ -2261,8 +2440,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
|
advisorModel,
|
||||||
statusLineCommand,
|
statusLineCommand,
|
||||||
sessionName: name,
|
sessionName: cliName,
|
||||||
});
|
});
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
const cmd = wrapWithNice(baseCmd, config);
|
const cmd = wrapWithNice(baseCmd, config);
|
||||||
@@ -2297,6 +2477,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
);
|
);
|
||||||
// Wait for the respawned process to start
|
// Wait for the respawned process to start
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
|
// The pane now runs a fresh command, so whatever the last read observed of
|
||||||
|
// the old one is history. Clearing it here rather than waiting for the next
|
||||||
|
// poll also invalidates any read already in flight, which would otherwise
|
||||||
|
// write the old death back over the pane that just replaced it.
|
||||||
|
this.clearPaneExit(muxName);
|
||||||
const pid = this.getPanePid(muxName);
|
const pid = this.getPanePid(muxName);
|
||||||
if (pid) session.pid = pid;
|
if (pid) session.pid = pid;
|
||||||
return pid;
|
return pid;
|
||||||
@@ -2508,6 +2693,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.lastPaneCount.delete(session.muxName);
|
this.lastPaneCount.delete(session.muxName);
|
||||||
|
this.clearPaneExit(session.muxName);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
this.clearRemoteReconnectState(sessionId);
|
this.clearRemoteReconnectState(sessionId);
|
||||||
this.saveSessions();
|
this.saveSessions();
|
||||||
@@ -2618,6 +2804,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.lastPaneCount.delete(session.muxName);
|
this.lastPaneCount.delete(session.muxName);
|
||||||
|
this.clearPaneExit(session.muxName);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
this.clearRemoteReconnectState(sessionId);
|
this.clearRemoteReconnectState(sessionId);
|
||||||
this.saveSessions();
|
this.saveSessions();
|
||||||
@@ -2671,7 +2858,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
}).trim();
|
}).trim();
|
||||||
active = parsePaneList(output);
|
const rows = parsePaneRows(output);
|
||||||
|
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
|
||||||
|
// The same read answers both questions, so recovery starts with a pane-exit
|
||||||
|
// reading rather than waiting for the first stats tick — which may never
|
||||||
|
// come, since the collector only starts when boot found a live session.
|
||||||
|
if (rows.length > 0) {
|
||||||
|
this.applyPaneExits(derivePaneExits(rows, Date.now()));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||||||
active = new Map();
|
active = new Map();
|
||||||
@@ -2686,6 +2880,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
} else {
|
} else {
|
||||||
dead.push(sessionId);
|
dead.push(sessionId);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
this.clearPaneExit(session.muxName);
|
||||||
this.clearRemoteReconnectState(sessionId);
|
this.clearRemoteReconnectState(sessionId);
|
||||||
this.emit('sessionDied', { sessionId });
|
this.emit('sessionDied', { sessionId });
|
||||||
}
|
}
|
||||||
@@ -2722,6 +2917,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
attached: false,
|
attached: false,
|
||||||
name: `Restored: ${sessionName}`,
|
name: `Restored: ${sessionName}`,
|
||||||
|
// Every field above except the name and the pid is a guess: this record
|
||||||
|
// was rebuilt from the socket because Codeman's own bookkeeping did not
|
||||||
|
// have it. The synthetic id also cannot find the session's state.json
|
||||||
|
// entry, so a remote or docker session rediscovered this way arrives
|
||||||
|
// looking local. Consumers that would be wrong about such a session
|
||||||
|
// read this flag and fail closed — see `Session.paneExitApplies`.
|
||||||
|
discovered: true,
|
||||||
};
|
};
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
knownMuxNames.add(sessionName);
|
knownMuxNames.add(sessionName);
|
||||||
@@ -2882,6 +3084,185 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||||
|
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||||
|
* that is running, a tmux session that no longer exists, a probe that failed,
|
||||||
|
* and every poll that has not run yet. See {@link PaneExit}.
|
||||||
|
*/
|
||||||
|
getPaneExit(muxName: string): PaneExit | undefined {
|
||||||
|
return this.paneExits.get(muxName)?.exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many authoritative pane reads have agreed on the exit that
|
||||||
|
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
|
||||||
|
* starts at 1, and every later read that sees the same pane with the same
|
||||||
|
* status and signal adds one.
|
||||||
|
*/
|
||||||
|
getPaneExitReadCount(muxName: string): number {
|
||||||
|
return this.paneExits.get(muxName)?.reads ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
||||||
|
* `tmux list-panes -a` answers for every session at once, which is why this
|
||||||
|
* polls rather than probing per session.
|
||||||
|
*
|
||||||
|
* A failed or empty probe leaves the previous answers ALONE rather than
|
||||||
|
* clearing them, because the two cannot be told apart: the command ends in
|
||||||
|
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
|
||||||
|
* arrive as empty output. Treating that as "tmux did not answer" is the
|
||||||
|
* conservative reading — clearing on it would turn a transient failure into a
|
||||||
|
* silent retraction of a death Codeman had already observed, and the cost of
|
||||||
|
* being wrong the other way is one stale entry for a socket that no longer
|
||||||
|
* has the pane. A NON-empty read is different: `list-panes -a` lists
|
||||||
|
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
|
||||||
|
* prunes against it.
|
||||||
|
*
|
||||||
|
* Two guards keep a slow read from undoing a fast one. A read already in
|
||||||
|
* flight suppresses the next poll, and a read that started before a
|
||||||
|
* {@link clearPaneExit} is discarded when it lands.
|
||||||
|
*
|
||||||
|
* A third guard skips the read entirely while no session on this manager
|
||||||
|
* could produce an answer ({@link hasObservablePaneSession}). Skipping
|
||||||
|
* retracts nothing, for the same reason a failed read does not: the map
|
||||||
|
* still holds what the last real read saw, and every path that puts a new
|
||||||
|
* command in a pane calls {@link clearPaneExit} itself.
|
||||||
|
*/
|
||||||
|
async refreshPaneExits(now: number = Date.now()): Promise<void> {
|
||||||
|
// Nothing on this socket could answer, so do not read tmux to find that
|
||||||
|
// out. See `hasObservablePaneSession`: the watcher above still ticks.
|
||||||
|
if (!hasObservablePaneSession(this.sessions.values())) return;
|
||||||
|
if (this.paneExitReadInFlight) return;
|
||||||
|
|
||||||
|
const generation = this.paneExitGeneration;
|
||||||
|
this.paneExitReadInFlight = true;
|
||||||
|
let rows: PaneRow[];
|
||||||
|
try {
|
||||||
|
rows = await this.readPaneRows();
|
||||||
|
} finally {
|
||||||
|
this.paneExitReadInFlight = false;
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
// A pane was respawned or killed while this read was out, so what it saw is
|
||||||
|
// already history. Dropping it is what stops a freshly respawned pane from
|
||||||
|
// being republished as exited.
|
||||||
|
if (generation !== this.paneExitGeneration) return;
|
||||||
|
|
||||||
|
this.applyPaneExits(derivePaneExits(rows, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
|
||||||
|
* touches tmux, which is what lets a test subclass drive the guards in
|
||||||
|
* {@link refreshPaneExits} — the in-flight suppression, the generation
|
||||||
|
* check, the empty-read retraction rule and the read gate — against rows it
|
||||||
|
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
|
||||||
|
* test can reach is a guard that can be deleted without anything failing.
|
||||||
|
*
|
||||||
|
* A failed read answers with NO rows, which the caller treats as "tmux did
|
||||||
|
* not answer" and which therefore retracts nothing.
|
||||||
|
*/
|
||||||
|
protected async readPaneRows(): Promise<PaneRow[]> {
|
||||||
|
// The test-mode gate lives HERE rather than at the top of the tick, so that
|
||||||
|
// what tests cannot do is spawn a process, not exercise the bookkeeping.
|
||||||
|
if (IS_TEST_MODE) return [];
|
||||||
|
try {
|
||||||
|
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
|
||||||
|
// exec freezes the port while the process stays alive (see the
|
||||||
|
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
|
||||||
|
// stay synchronous because each is answering one request right then.
|
||||||
|
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return parsePaneRows(stdout.trim());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TmuxManager] Failed to read pane exit state:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one authoritative observation into {@link paneExits}. Split out from
|
||||||
|
* the tmux call so the merge rules are unit-testable.
|
||||||
|
*
|
||||||
|
* `observed` comes from a read of EVERY pane on the socket, so a session
|
||||||
|
* missing from it has no exit to report and its entry goes. That is what
|
||||||
|
* keeps the map from growing without bound as tmux sessions come and go
|
||||||
|
* outside `killSession()`. Only the caller may decide a read is authoritative:
|
||||||
|
* a failed or empty one never reaches here.
|
||||||
|
*
|
||||||
|
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
|
||||||
|
* says when the agent was found gone rather than when the last poll ran. A
|
||||||
|
* changed status, a changed signal, or a different pane pid all start a new
|
||||||
|
* observation — the pid is what catches a second command in the same pane
|
||||||
|
* that happened to exit the same way.
|
||||||
|
*
|
||||||
|
* The same rule decides the read count: a repeat of the stored exit adds one,
|
||||||
|
* and anything that starts a new observation starts the count again at 1.
|
||||||
|
*/
|
||||||
|
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
||||||
|
for (const muxName of [...this.paneExits.keys()]) {
|
||||||
|
if (!observed.has(muxName)) this.paneExits.delete(muxName);
|
||||||
|
}
|
||||||
|
for (const [muxName, next] of observed) {
|
||||||
|
const prev = this.paneExits.get(muxName);
|
||||||
|
const sameExit =
|
||||||
|
prev !== undefined &&
|
||||||
|
prev.panePid === next.panePid &&
|
||||||
|
prev.exit.status === next.exit.status &&
|
||||||
|
prev.exit.signal === next.exit.signal;
|
||||||
|
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a session's exit observation, e.g. once its pane has been respawned.
|
||||||
|
* Also invalidates any read already in flight, so the answer this retracts
|
||||||
|
* cannot be written back a moment later.
|
||||||
|
*/
|
||||||
|
clearPaneExit(muxName: string): void {
|
||||||
|
this.paneExits.delete(muxName);
|
||||||
|
this.paneExitGeneration++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for exited agents, on the manager's own interval.
|
||||||
|
*
|
||||||
|
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
|
||||||
|
* when the browser opens the Monitor panel and DISARMED when it closes it
|
||||||
|
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
|
||||||
|
* recovered — so riding it would leave a session created on a freshly booted
|
||||||
|
* server reporting nothing at all, and would let one browser turn exit
|
||||||
|
* detection off for every other. Started unconditionally, like the mouse-mode
|
||||||
|
* sync and the remote-reconnect watcher below.
|
||||||
|
*
|
||||||
|
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
|
||||||
|
* SSE event, and the field reaches the browser on `session:updated`.
|
||||||
|
*/
|
||||||
|
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
|
||||||
|
if (this.paneExitInterval) {
|
||||||
|
clearInterval(this.paneExitInterval);
|
||||||
|
}
|
||||||
|
this.paneExitInterval = setInterval(() => {
|
||||||
|
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
|
||||||
|
// spawn a process and it refuses under test, so a test can drive this
|
||||||
|
// whole loop with fake timers instead of being locked out of it.
|
||||||
|
void this.refreshPaneExits()
|
||||||
|
.then(() => this.emit('paneExitsUpdated'))
|
||||||
|
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPaneExitWatcher(): void {
|
||||||
|
if (this.paneExitInterval) {
|
||||||
|
clearInterval(this.paneExitInterval);
|
||||||
|
this.paneExitInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
||||||
if (this.statsInterval) {
|
if (this.statsInterval) {
|
||||||
clearInterval(this.statsInterval);
|
clearInterval(this.statsInterval);
|
||||||
@@ -3101,6 +3482,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.stopStatsCollection();
|
this.stopStatsCollection();
|
||||||
|
this.stopPaneExitWatcher();
|
||||||
|
this.paneExits.clear();
|
||||||
this.stopMouseModeSync();
|
this.stopMouseModeSync();
|
||||||
this.stopRemoteReconnectWatcher();
|
this.stopRemoteReconnectWatcher();
|
||||||
this.reconnectState.clear();
|
this.reconnectState.clear();
|
||||||
|
|||||||
@@ -28,8 +28,11 @@
|
|||||||
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
|
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
|
||||||
import type { TuiApprovalAnswer } from './tui-client.js';
|
import type { TuiApprovalAnswer } from './tui-client.js';
|
||||||
|
|
||||||
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
|
/**
|
||||||
export type TuiApprovalTone = 'err' | 'warn';
|
* Card severity, in the same red/yellow vocabulary the web inbox uses, plus the quiet
|
||||||
|
* third case: an item that opened acknowledged asks for nothing and reads grey.
|
||||||
|
*/
|
||||||
|
export type TuiApprovalTone = 'err' | 'warn' | 'info';
|
||||||
|
|
||||||
export interface TuiApprovalCard {
|
export interface TuiApprovalCard {
|
||||||
tone: TuiApprovalTone;
|
tone: TuiApprovalTone;
|
||||||
@@ -50,8 +53,14 @@ function clean(text: string | undefined): string {
|
|||||||
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
|
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How loud the card is. An idle prompt the inbox opened ALREADY acknowledged is not
|
||||||
|
* asking for anything — the session is watching work it started itself — so it drops to
|
||||||
|
* `info` and out of the warning vocabulary the other two share with the web inbox.
|
||||||
|
*/
|
||||||
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
|
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
|
||||||
return item.kind === 'idle' ? 'warn' : 'err';
|
if (item.kind !== 'idle') return 'err';
|
||||||
|
return item.acknowledgedReason ? 'info' : 'warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,9 +74,13 @@ export function approvalCard(item: ApprovalItem): TuiApprovalCard {
|
|||||||
const summary = clean(item.toolSummary) || clean(item.toolName);
|
const summary = clean(item.toolSummary) || clean(item.toolName);
|
||||||
|
|
||||||
if (item.kind === 'idle') {
|
if (item.kind === 'idle') {
|
||||||
|
// Say what it is waiting for rather than asking for a reply, in the same words the
|
||||||
|
// web drawer uses for the same item. The prompt is still answerable, so the hint
|
||||||
|
// stays either way.
|
||||||
|
const quiet = clean(item.acknowledgedReason);
|
||||||
return {
|
return {
|
||||||
tone: 'warn',
|
tone: approvalTone(item),
|
||||||
title: message || 'waiting for your reply',
|
title: quiet ? `quiet, ${quiet}` : message || 'waiting for your reply',
|
||||||
detail: [],
|
detail: [],
|
||||||
options: [],
|
options: [],
|
||||||
hint: 'p to reply',
|
hint: 'p to reply',
|
||||||
|
|||||||
+18
-2
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
|
|||||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||||
* errored session has no state of its own here and joins the waiting tier,
|
* errored session has no state of its own here and joins the waiting tier,
|
||||||
* since it is equally something only a human can clear.
|
* since it is equally something only a human can clear.
|
||||||
|
*
|
||||||
|
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
|
||||||
|
* alert this prompt armed has been spent, either because somebody opened the
|
||||||
|
* session on another device or because the inbox opened the item that way for a
|
||||||
|
* session watching its own background work. The item itself stays pending and
|
||||||
|
* answerable, so the row keeps carrying it and the approval card still renders;
|
||||||
|
* it simply stops dragging the session into NEEDS YOU. The web has honoured
|
||||||
|
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
|
||||||
|
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
|
||||||
|
* reading past it: acknowledging on a phone cleared the alert everywhere except
|
||||||
|
* here. Only `idle` can be acknowledged, so a permission or question dialog is
|
||||||
|
* unaffected by construction, and both are checked ahead of the flag anyway.
|
||||||
*/
|
*/
|
||||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||||
if (!isLiveRow(session)) return 'recent';
|
if (!isLiveRow(session)) return 'recent';
|
||||||
if (approval) {
|
if (approval) {
|
||||||
if (approval.kind === 'permission') return 'blocked-permission';
|
if (approval.kind === 'permission') return 'blocked-permission';
|
||||||
if (approval.kind === 'question') return 'blocked-question';
|
if (approval.kind === 'question') return 'blocked-question';
|
||||||
return 'waiting';
|
if (!approval.acknowledgedAt) return 'waiting';
|
||||||
}
|
}
|
||||||
if (session.status === 'error') return 'waiting';
|
if (session.status === 'error') return 'waiting';
|
||||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||||
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
|
|||||||
* turn's own start is the pane's last Enter.
|
* turn's own start is the pane's last Enter.
|
||||||
*/
|
*/
|
||||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||||
if (approval) return approval.createdAt;
|
// The prompt's own age measures the state only while the prompt is what put the
|
||||||
|
// row in that state. An acknowledged item still rides along on a row that is
|
||||||
|
// plainly idle or working, and dating such a row from it would report how long
|
||||||
|
// ago the prompt arrived as though it were how long the session has been quiet.
|
||||||
|
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
|
||||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -462,7 +462,8 @@ export function computeListWindow(
|
|||||||
* The pending dialog, drawn above the tail: the question, the parsed options
|
* The pending dialog, drawn above the tail: the question, the parsed options
|
||||||
* with their digits, and the keys that answer them. Red for a permission or
|
* with their digits, and the keys that answer them. Red for a permission or
|
||||||
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
||||||
* inbox uses.
|
* inbox uses. An idle prompt that opened acknowledged carries neither: it reads
|
||||||
|
* grey with the idle glyph, because nothing about it wants the reader.
|
||||||
*/
|
*/
|
||||||
export function renderApprovalCard(
|
export function renderApprovalCard(
|
||||||
item: ApprovalItem,
|
item: ApprovalItem,
|
||||||
@@ -472,8 +473,8 @@ export function renderApprovalCard(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const paint = painterFor(opts.color);
|
const paint = painterFor(opts.color);
|
||||||
const card = approvalCard(item);
|
const card = approvalCard(item);
|
||||||
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
|
const color = card.tone === 'err' ? SGR.red : card.tone === 'warn' ? SGR.yellow : SGR.gray;
|
||||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
|
const glyph = card.tone === 'err' ? glyphs.blockedPermission : card.tone === 'warn' ? glyphs.waiting : glyphs.idle;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const push = (text: string, style: string): void => {
|
const push = (text: string, style: string): void => {
|
||||||
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
||||||
@@ -571,10 +572,19 @@ function previewBody(
|
|||||||
// Chrome
|
// Chrome
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
|
/**
|
||||||
|
* Sessions with a prompt waiting on a human, which is what the badge counts.
|
||||||
|
*
|
||||||
|
* An ACKNOWLEDGED item is not one of them. Its alert has been spent, either by somebody
|
||||||
|
* opening the session elsewhere or because the inbox opened it that way for a session
|
||||||
|
* watching its own background work, and the row has already left NEEDS YOU by the same
|
||||||
|
* flag (`classifySession`). Counting it here would put a number in the header for a
|
||||||
|
* group the reader can see is empty.
|
||||||
|
*/
|
||||||
export function pendingApprovalCount(model: TuiRenderModel): number {
|
export function pendingApprovalCount(model: TuiRenderModel): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
|
for (const group of model.groups())
|
||||||
|
for (const row of group.rows) if (row.approval && !row.approval.acknowledgedAt) count++;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -24,9 +24,10 @@
|
|||||||
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
|
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
|
||||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
|
||||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||||
|
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||||
*
|
*
|
||||||
* ## Cross-domain relationship map
|
* ## Cross-domain relationship map
|
||||||
*
|
*
|
||||||
@@ -72,3 +73,4 @@ export * from './search.js';
|
|||||||
export * from './user.js';
|
export * from './user.js';
|
||||||
export * from './webview.js';
|
export * from './webview.js';
|
||||||
export * from './intent.js';
|
export * from './intent.js';
|
||||||
|
export * from './mcp-sync.js';
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
|
||||||
|
*
|
||||||
|
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
|
||||||
|
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One participating CLI in a sync result. */
|
||||||
|
export interface McpSyncTargetResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** The config file read (and written). For a `skipped` target, the unresolved location. */
|
||||||
|
file: string;
|
||||||
|
/**
|
||||||
|
* `absent`: not installed and no config file, so neither read nor created.
|
||||||
|
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
|
||||||
|
* var is a relative path), so it is neither read nor written; `error` says why.
|
||||||
|
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
|
||||||
|
* `failed`: a read or write error (the file may be unchanged).
|
||||||
|
*/
|
||||||
|
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
|
||||||
|
/** Why the target is not `ok`. Position or category only, never file content. */
|
||||||
|
error?: string;
|
||||||
|
servers: string[];
|
||||||
|
/** Servers added (apply) or that would be added (plan). */
|
||||||
|
added: string[];
|
||||||
|
/** Missing servers this dialect cannot express. */
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `data` of `GET`/`POST /api/mcp-sync`. */
|
||||||
|
export interface McpSyncResult {
|
||||||
|
applied: boolean;
|
||||||
|
targets: McpSyncTargetResult[];
|
||||||
|
/** Names defined differently by different CLIs; existing definitions are left untouched. */
|
||||||
|
conflicts: string[];
|
||||||
|
/** Names left out because the only definitions are switched off in their own CLI. */
|
||||||
|
disabled: string[];
|
||||||
|
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
|
||||||
|
unsupported: string[];
|
||||||
|
}
|
||||||
+43
-2
@@ -6,13 +6,17 @@
|
|||||||
* Key exports:
|
* Key exports:
|
||||||
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
|
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
|
||||||
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
|
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
|
||||||
|
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
|
||||||
|
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
|
||||||
*
|
*
|
||||||
* Persistence:
|
* Persistence:
|
||||||
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
|
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
|
||||||
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
|
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
|
||||||
|
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
|
||||||
*
|
*
|
||||||
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
|
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
|
||||||
* `POST /api/push/subscribe`. No dependencies on other domain modules.
|
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
|
||||||
|
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A registered push subscription */
|
/** A registered push subscription */
|
||||||
@@ -32,3 +36,40 @@ export interface VapidKeys {
|
|||||||
privateKey: string;
|
privateKey: string;
|
||||||
generatedAt: number;
|
generatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Services the webhook channel can format a message for. */
|
||||||
|
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
|
||||||
|
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
|
||||||
|
|
||||||
|
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
|
||||||
|
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
|
||||||
|
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
|
||||||
|
|
||||||
|
export type WebhookUrgency = 'critical' | 'warning' | 'info';
|
||||||
|
|
||||||
|
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
|
||||||
|
export interface WebhookConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
kind: WebhookKind;
|
||||||
|
url: string;
|
||||||
|
scope: WebhookScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One delivery attempt. `error` never contains the URL. */
|
||||||
|
export interface WebhookResult {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
error?: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
|
||||||
|
export interface WebhookStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
kind: WebhookKind;
|
||||||
|
scope: WebhookScope;
|
||||||
|
hasUrl: boolean;
|
||||||
|
/** Scheme + host only; the path and query are the secret. */
|
||||||
|
urlMasked: string;
|
||||||
|
lastResult: WebhookResult | null;
|
||||||
|
}
|
||||||
|
|||||||
+108
-1
@@ -12,7 +12,7 @@
|
|||||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
|
||||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||||
@@ -377,6 +377,36 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
|
|||||||
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
|
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
|
||||||
|
* Which of them a given model honours is codex's business; Codeman only keeps the value
|
||||||
|
* to a known word, since it lands in the launch argv.
|
||||||
|
*/
|
||||||
|
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
|
||||||
|
|
||||||
|
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
|
||||||
|
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
|
||||||
|
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
|
||||||
|
* out on purpose: it can call an advisor but never act as one.
|
||||||
|
*/
|
||||||
|
export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const;
|
||||||
|
|
||||||
|
/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */
|
||||||
|
const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard: is the value an advisor model Codeman will pass to claude? An alias from
|
||||||
|
* ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the
|
||||||
|
* injection guard: the value is rendered inside the single-quoted `--settings` JSON argument.
|
||||||
|
*/
|
||||||
|
export function isAdvisorModel(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string' || value.length > 64) return false;
|
||||||
|
return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
/** OpenCode session configuration */
|
/** OpenCode session configuration */
|
||||||
export interface OpenCodeConfig {
|
export interface OpenCodeConfig {
|
||||||
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
|
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
|
||||||
@@ -398,6 +428,8 @@ export type CodexRenderMode = 'hybrid';
|
|||||||
export interface CodexConfig {
|
export interface CodexConfig {
|
||||||
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
|
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
|
||||||
|
reasoningEffort?: CodexReasoningEffort;
|
||||||
/** Resume a previous codex conversation by session id (passed via --resume) */
|
/** Resume a previous codex conversation by session id (passed via --resume) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||||
@@ -625,6 +657,55 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
|||||||
launchModel?: string;
|
launchModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
|
||||||
|
*
|
||||||
|
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
|
||||||
|
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
|
||||||
|
* process Codeman records as the session's pid. No PTY exit handler runs, so
|
||||||
|
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
|
||||||
|
*
|
||||||
|
* The field is TRI-STATE, and the third state is the absence of the field:
|
||||||
|
* `undefined` means Codeman does not know, and it must never be rendered as
|
||||||
|
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
|
||||||
|
* SSH session (the local pane holds the ssh client, whose death means transport
|
||||||
|
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
|
||||||
|
* into the container's own tmux).
|
||||||
|
*
|
||||||
|
* `status` and `signal` are independently optional because tmux may know that
|
||||||
|
* the pane died without reporting how. Measured on tmux 3.2a: a SIGKILLed pane
|
||||||
|
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
|
||||||
|
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
|
||||||
|
* absent `status` means "the exit code is unknown", never "the exit code is 0".
|
||||||
|
*
|
||||||
|
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
||||||
|
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
||||||
|
* the absent status IS how a signal death presents, so absent-stays-absent is
|
||||||
|
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
|
||||||
|
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
|
||||||
|
* read as a user typing `/exit` and be closed. Nothing here fails when
|
||||||
|
* somebody adds that `??` — the types allow it and the label still renders.
|
||||||
|
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
|
||||||
|
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
|
||||||
|
* accepts only an explicit 0.
|
||||||
|
*/
|
||||||
|
export interface PaneExit {
|
||||||
|
/**
|
||||||
|
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
|
||||||
|
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
|
||||||
|
* giving this a default anywhere.
|
||||||
|
*/
|
||||||
|
status?: number;
|
||||||
|
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
|
||||||
|
signal?: number;
|
||||||
|
/**
|
||||||
|
* Wall-clock ms when THIS server process first observed the pane dead. It is
|
||||||
|
* not when the agent exited, which nothing records, and a restart that finds
|
||||||
|
* the pane still dead respawns it rather than re-timing the old exit.
|
||||||
|
*/
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
/** Unique session identifier */
|
/** Unique session identifier */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -746,6 +827,17 @@ export interface SessionState {
|
|||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
|
||||||
|
advisorModel?: string;
|
||||||
|
/**
|
||||||
|
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
|
||||||
|
* the app-wide default when there was none. Persisted so a recovered session relaunches on
|
||||||
|
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
|
||||||
|
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
|
||||||
|
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
|
||||||
|
* absent for them.
|
||||||
|
*/
|
||||||
|
model?: string;
|
||||||
/**
|
/**
|
||||||
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
|
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
|
||||||
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
|
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
|
||||||
@@ -780,6 +872,21 @@ export interface SessionState {
|
|||||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||||
*/
|
*/
|
||||||
respawnBlocked?: boolean;
|
respawnBlocked?: boolean;
|
||||||
|
/**
|
||||||
|
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
|
||||||
|
* See {@link PaneExit} for the tri-state rule and for which session shapes
|
||||||
|
* leave it absent. `status` and `pid` are deliberately untouched by it: the
|
||||||
|
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
|
||||||
|
* browser re-attach and launch a fresh CLI.
|
||||||
|
*
|
||||||
|
* Persisted so a reboot restore can tell a session whose agent exited from one
|
||||||
|
* that was merely idle when the power went. `reboot-restore.ts` reads the
|
||||||
|
* persisted record and never builds a `Session`, so the record is the only
|
||||||
|
* place that survives the reboot to carry it. Nothing reads it there YET:
|
||||||
|
* making the restore refuse such a session is a behavior change, and it
|
||||||
|
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
|
||||||
|
*/
|
||||||
|
paneExit?: PaneExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Does a Claude conversation transcript exist on this host?
|
||||||
|
*
|
||||||
|
* Claude writes one `<conversation-id>.jsonl` per conversation under
|
||||||
|
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
|
||||||
|
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
|
||||||
|
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
|
||||||
|
*
|
||||||
|
* The project directory name is derived from the working directory, and a case
|
||||||
|
* that has been moved or renamed leaves its transcript under the OLD name, so
|
||||||
|
* the search is across every project directory rather than the one that matches
|
||||||
|
* the pane's cwd today.
|
||||||
|
*
|
||||||
|
* ⚠️ Existence is the whole question here, with no size floor. The create route
|
||||||
|
* additionally requires ~4 KB before it will resume, which is a "is this
|
||||||
|
* conversation worth resuming" judgement; for a relaunch the question is the
|
||||||
|
* opposite one — a one-line transcript still makes `--session-id` collide.
|
||||||
|
*
|
||||||
|
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
|
||||||
|
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
|
||||||
|
* that id either, so a lookup that misses the real config dir turns a
|
||||||
|
* recoverable pane into the collision this module exists to prevent.
|
||||||
|
*
|
||||||
|
* @dependencies none
|
||||||
|
* @consumedby session (relaunch resume pinning)
|
||||||
|
*
|
||||||
|
* @module utils/claude-transcript
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir, stat } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
|
||||||
|
* (#255) and, failing that, the server process's own.
|
||||||
|
*
|
||||||
|
* ⚠️ The process env is not optional here. A pane inherits the server's
|
||||||
|
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
|
||||||
|
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
|
||||||
|
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
|
||||||
|
* (claude-credentials.ts) and `realClaudeConfigDir()`
|
||||||
|
* (custom-model-injection-apply.ts) resolve the same directory the same way.
|
||||||
|
*/
|
||||||
|
export function claudeProjectsDir(configDir?: string): string {
|
||||||
|
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
|
||||||
|
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a transcript for `conversationId` exists under any project
|
||||||
|
* directory. Returns false for a missing projects dir or an unreadable one,
|
||||||
|
* which leaves the caller unpinned: safe where nothing else can collide with
|
||||||
|
* the bare `--session-id`, and the reason the caller walks its candidates down
|
||||||
|
* to the session's own id rather than treating one false answer as final.
|
||||||
|
*/
|
||||||
|
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
|
||||||
|
if (!conversationId) return false;
|
||||||
|
const projectsDir = claudeProjectsDir(configDir);
|
||||||
|
let projectDirs: string[];
|
||||||
|
try {
|
||||||
|
projectDirs = await readdir(projectsDir);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
try {
|
||||||
|
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Not in this project directory; keep looking.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -204,6 +204,28 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-binary invalidation generation, bumped by `invalidateCliExecutableResolvers()`.
|
||||||
|
*
|
||||||
|
* Every resolver instance (each per-CLI module's private one AND the generic registry
|
||||||
|
* resolver in cli-resolver.ts) is built by the factory below and caches in its own
|
||||||
|
* closure, so there is no instance to reach from outside. Keying on the BINARY name is
|
||||||
|
* what lets one call reach all of them: the CLI-management install/update routes know
|
||||||
|
* which binaries just changed, and every resolver knows its own.
|
||||||
|
*/
|
||||||
|
const binaryGenerations = new Map<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget every cached result — success and negative-cache backoff alike — for these
|
||||||
|
* binaries, so the next `resolve()` re-runs the chain immediately. For an action that
|
||||||
|
* just changed what is on disk (an install) or what a CLI's binary IS (editing a custom
|
||||||
|
* entry): without it a CLI installed from Settings kept reading as missing for up to the
|
||||||
|
* 5-minute backoff, and an edited entry kept launching its old binary until a restart.
|
||||||
|
*/
|
||||||
|
export function invalidateCliExecutableResolvers(binaries: readonly string[]): void {
|
||||||
|
for (const binary of binaries) binaryGenerations.set(binary, (binaryGenerations.get(binary) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCliExecutableResolver<T = undefined>(
|
export function createCliExecutableResolver<T = undefined>(
|
||||||
options: {
|
options: {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -235,6 +257,8 @@ export function createCliExecutableResolver<T = undefined>(
|
|||||||
let failures = 0;
|
let failures = 0;
|
||||||
/** Timestamp of the most recent miss. */
|
/** Timestamp of the most recent miss. */
|
||||||
let lastFailureAt = 0;
|
let lastFailureAt = 0;
|
||||||
|
/** The invalidation generation the cached state above belongs to. */
|
||||||
|
let generation = binaryGenerations.get(options.binary) ?? 0;
|
||||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||||
@@ -249,6 +273,13 @@ export function createCliExecutableResolver<T = undefined>(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
resolve() {
|
resolve() {
|
||||||
|
const current = binaryGenerations.get(options.binary) ?? 0;
|
||||||
|
if (current !== generation) {
|
||||||
|
generation = current;
|
||||||
|
cached = null;
|
||||||
|
failures = 0;
|
||||||
|
lastFailureAt = 0;
|
||||||
|
}
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
// Negative cache: a miss is remembered and the chain — whose login-shell
|
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||||
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user