mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 06:59:33 +01:00
Compare commits
225
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa6016d33 | ||
|
|
1921a47aa8 | ||
|
|
4960bee779 | ||
|
|
eb3b08d58b | ||
|
|
7803d5616a | ||
|
|
345c901e81 | ||
|
|
e68afdd2ac | ||
|
|
4276b53a8f | ||
|
|
126d967ba3 | ||
|
|
f36bea86c7 | ||
|
|
9d04a4fb02 | ||
|
|
7c8d440928 | ||
|
|
91d157cae4 | ||
|
|
4f50af6a9f | ||
|
|
dc6f8d27e8 | ||
|
|
2526fdb0d8 | ||
|
|
76e9f364f9 | ||
|
|
8c317d84c8 | ||
|
|
4c29aedbc5 | ||
|
|
6232dffe03 | ||
|
|
f7dfe091c5 | ||
|
|
3bcb55ce96 | ||
|
|
7b8e295647 | ||
|
|
0dbfc8dc2d | ||
|
|
927c9a461e | ||
|
|
d9d6fad698 | ||
|
|
823013226d | ||
|
|
5e7ec1c522 | ||
|
|
1efe1d40c3 | ||
|
|
9d6f84f446 | ||
|
|
58f7dbc7ad | ||
|
|
7ed660ff11 | ||
|
|
457dd54825 | ||
|
|
de2bfb69c3 | ||
|
|
6670bc64b6 | ||
|
|
109fd98038 | ||
|
|
de43657676 | ||
|
|
4aeab0d9ce | ||
|
|
4623ee0d12 | ||
|
|
e1c792f491 | ||
|
|
1f0823c582 | ||
|
|
884e67d07e | ||
|
|
b177885dd7 | ||
|
|
5c8a557390 | ||
|
|
2cc5f3f9e6 | ||
|
|
181e419644 | ||
|
|
2db0449a54 | ||
|
|
82d251d52b | ||
|
|
d980d70173 | ||
|
|
fa2b81484b | ||
|
|
1464332e91 | ||
|
|
c646613c56 | ||
|
|
aece02da87 | ||
|
|
1b8ac40baf | ||
|
|
dbd1b6e587 | ||
|
|
a97fa0de17 | ||
|
|
5582ec523b | ||
|
|
ed67596387 | ||
|
|
6a23fecb1c | ||
|
|
7a3629e890 | ||
|
|
41186f5ac3 | ||
|
|
869633ae6d | ||
|
|
b549a66aa3 | ||
|
|
d5e4fe0f87 | ||
|
|
086249a386 | ||
|
|
68bd7e4d15 | ||
|
|
0bd15ddc84 | ||
|
|
d5a5468b02 | ||
|
|
c5d0e4b8b4 | ||
|
|
021641911e | ||
|
|
e5d82eb044 | ||
|
|
093f242662 | ||
|
|
d6c7d30bd4 | ||
|
|
97c698b249 | ||
|
|
9f18b78857 | ||
|
|
d044e49635 | ||
|
|
a698729fb6 | ||
|
|
b68792f988 | ||
|
|
72a59de96c | ||
|
|
212989b349 | ||
|
|
dc22519adb | ||
|
|
1ab54b9e9b | ||
|
|
0db9b55961 | ||
|
|
2d4036cf1e | ||
|
|
6d4357502b | ||
|
|
e95d38a944 | ||
|
|
eb0553a645 | ||
|
|
aedefab8b4 | ||
|
|
902d4491e5 | ||
|
|
e3ca07d59d | ||
|
|
dcf51b1715 | ||
|
|
1c4338e8b5 | ||
|
|
61bf26cfdf | ||
|
|
f23e18b1fc | ||
|
|
54c1692350 | ||
|
|
f91cd343be | ||
|
|
ee97daa4f0 | ||
|
|
3847e377d8 | ||
|
|
168c15ae08 | ||
|
|
fd0e52496d | ||
|
|
97d53f6621 | ||
|
|
1607b1e488 | ||
|
|
5ce8213726 | ||
|
|
f1f7eec4d6 | ||
|
|
1ab01bb7ef | ||
|
|
8074f67c6a | ||
|
|
ca0f891c2b | ||
|
|
9f7cba07ab | ||
|
|
1ad9db7606 | ||
|
|
593b2eb2ea | ||
|
|
5add56eaf6 | ||
|
|
9ee6df027d | ||
|
|
f1df8f8c0a | ||
|
|
ad3f6328e6 | ||
|
|
6d640b08ce | ||
|
|
9fec26009f | ||
|
|
66528962ee | ||
|
|
20cd0226a5 | ||
|
|
cdff4d4d98 | ||
|
|
c708312a3f | ||
|
|
79a7f494b1 | ||
|
|
dd56674a1d | ||
|
|
74fc3ea6ae | ||
|
|
ad2f06ce8b | ||
|
|
6f820ca990 | ||
|
|
a1406f1753 | ||
|
|
8d75e6bd15 | ||
|
|
affe91d7ed | ||
|
|
b63935fa66 | ||
|
|
591eebe6e5 | ||
|
|
46122247e4 | ||
|
|
2c2f69adba | ||
|
|
dd09a383f1 | ||
|
|
ecf28fa3be | ||
|
|
8a6e34bece | ||
|
|
b0487cc9af | ||
|
|
c04fa97f78 | ||
|
|
285aa0b2b2 | ||
|
|
de1aab9b86 | ||
|
|
cc08016a76 | ||
|
|
93691e6096 | ||
|
|
3fdba31e36 | ||
|
|
1752d7ee2c | ||
|
|
b89f15c12f | ||
|
|
da740d4b00 | ||
|
|
0f1fca6ea0 | ||
|
|
98526527c8 | ||
|
|
b21b8485c3 | ||
|
|
5908cc51fd | ||
|
|
01cd509c2c | ||
|
|
6612a89ebc | ||
|
|
6d07a1059f | ||
|
|
bd3320ff90 | ||
|
|
cad8621324 | ||
|
|
97d2998c0c | ||
|
|
9c27342c7f | ||
|
|
3d0abbf21c | ||
|
|
82ef8b1edd | ||
|
|
97c91d4fbd | ||
|
|
6368128f1e | ||
|
|
6539bc6c82 | ||
|
|
0a56c1445f | ||
|
|
4b3eebd0ed | ||
|
|
e52cc60c7c | ||
|
|
04aa44b69c | ||
|
|
ffa7f57c8a | ||
|
|
8574e3c880 | ||
|
|
e93382f2f1 | ||
|
|
e9f3aa20a5 | ||
|
|
e8f66c6446 | ||
|
|
cd1b231876 | ||
|
|
3dafc89095 | ||
|
|
2aad93914e | ||
|
|
ecaddb40ae | ||
|
|
a29192a248 | ||
|
|
003398e209 | ||
|
|
fc0ecc6051 | ||
|
|
8fa5b340c1 | ||
|
|
4f6481bf7c | ||
|
|
c0d27079d7 | ||
|
|
0cc9332f3d | ||
|
|
3411d7f35a | ||
|
|
2832a037d6 | ||
|
|
ae3d31407b | ||
|
|
17b2edce22 | ||
|
|
8ad60facf3 | ||
|
|
b26234d6c6 | ||
|
|
ea4318947b | ||
|
|
9432d9f9e2 | ||
|
|
1082724db9 | ||
|
|
44fa2d4757 | ||
|
|
002d876556 | ||
|
|
803e7c7f85 | ||
|
|
c78635a3d3 | ||
|
|
1bdc341784 | ||
|
|
df9f93d3ee | ||
|
|
77ce4816a8 | ||
|
|
c5ffea0ed2 | ||
|
|
1a2c3be0ea | ||
|
|
4c2f050a96 | ||
|
|
a24b0ec7bb | ||
|
|
5e1daaf7c7 | ||
|
|
2a90fb8118 | ||
|
|
34f7766d43 | ||
|
|
d25d71b595 | ||
|
|
b2e96ddfaf | ||
|
|
e8ea8b0715 | ||
|
|
af4b65697a | ||
|
|
c7835a159c | ||
|
|
3e334357e8 | ||
|
|
405c9928d9 | ||
|
|
3a4e2c996e | ||
|
|
8cdd18fa64 | ||
|
|
27c93b20fb | ||
|
|
bf2a705705 | ||
|
|
1c87cd2ac7 | ||
|
|
74a107cf4f | ||
|
|
321268754a | ||
|
|
f5ce80dce2 | ||
|
|
dcdeaab98d | ||
|
|
95e2272f89 | ||
|
|
40a9528be1 | ||
|
|
efa647d257 | ||
|
|
32a1d544ab | ||
|
|
9f62da9ac3 |
No files matched your search
@@ -9,8 +9,8 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.45.0",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.48.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.48.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4960bee: ### Thanks
|
||||
- @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
|
||||
- @Randalix for the Edit history panel (#693).
|
||||
- @aakhter for relative links that follow your `cd` (#740).
|
||||
- @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
|
||||
- @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
|
||||
- @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
|
||||
|
||||
**Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "<message>" [--urgent] [--file <path>]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
|
||||
|
||||
**Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
|
||||
|
||||
**Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
|
||||
|
||||
**Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
|
||||
|
||||
**Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
|
||||
|
||||
**Fixes.**
|
||||
- **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
|
||||
- **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
|
||||
- **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
|
||||
- **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
|
||||
- **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
|
||||
- **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
|
||||
|
||||
**Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
|
||||
|
||||
**Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
|
||||
|
||||
## 1.47.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1ab01bb: ### Thanks
|
||||
- @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729).
|
||||
- @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname.
|
||||
- @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716.
|
||||
- @opticon454 for rendered Markdown that fills a resized File Viewer (#730).
|
||||
- @JDProfresh for a middle click on a file path that opens the file (#731).
|
||||
|
||||
**The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap.
|
||||
|
||||
**Fixes.**
|
||||
- **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before.
|
||||
- **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane.
|
||||
- **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder.
|
||||
- **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it.
|
||||
- **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click.
|
||||
- **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice.
|
||||
|
||||
**Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726).
|
||||
|
||||
**Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module.
|
||||
|
||||
## 1.46.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- de1aab9: ### Thanks
|
||||
- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
|
||||
- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
|
||||
- @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
|
||||
- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
|
||||
|
||||
**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
|
||||
|
||||
**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
|
||||
|
||||
**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
|
||||
|
||||
**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
|
||||
|
||||
**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
|
||||
|
||||
**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
|
||||
|
||||
**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
|
||||
|
||||
**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
|
||||
|
||||
**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
|
||||
|
||||
**Fixes.**
|
||||
- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
|
||||
- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
|
||||
- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
|
||||
- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
|
||||
|
||||
**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
|
||||
|
||||
**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
|
||||
|
||||
## 1.45.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
|
||||
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
|
||||
**Version**: 1.45.0 (must match `package.json`)
|
||||
**Version**: 1.48.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -129,9 +129,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
|
||||
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) No other CLI is told. `codeman notify "<message>" [--urgent] [--file <path>]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md`
|
||||
|
||||
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
|
||||
|
||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||
|
||||
@@ -175,7 +177,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||
|
||||
@@ -183,13 +185,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
@@ -226,21 +228,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
|
||||
|
||||
@@ -262,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||
|
||||
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
@@ -281,7 +289,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). Multi-user mode instead checks 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) BEFORE the password, so past either even a correct password from that IP waits (the account still logs in from another IP), plus 10 per username AFTER it (429 to wrong guesses from any IP, never to a correct password). QR and hook-secret have separate buckets, so neither can lock out login |
|
||||
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
||||
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
|
||||
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
||||
| **Tunnel** | Enabling a tunnel **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted). `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` acknowledges the BIND only and never satisfies this guard (#706: `install.sh` writes it for every passwordless LAN install). The authorization is decided ONCE per request, running or not, and required again at the `start()` site: the settings-file awaits between them let a concurrent stop land, and an unauthorized enable never writes `tunnelEnabled:true`. With no login the browser asks before every enable (welcome link, tunnel panel, App Settings save that turns it on) through ONE helper, `_confirmTunnelEnable()` (settings-ui.js), which reads `loginConfigured` from `GET /api/tunnel/status` (computed in the route by the guard's own test, never in `TunnelManager`); the 403 handler is the fallback when that read fails |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
@@ -302,7 +310,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate.
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ A setting the server ACTS on must be synced, never a display key and never stripped by `saveAppSettings`: a per-device value never reaches the server, so a scheduler or watcher reads nobody's number (the Git fetch rows are pinned by `test/app-settings-structure.test.ts`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate. ⚠️ Every write to the server's `settings.json` goes through `mutateSettingsFile()` (`src/web/settings-writer.ts`: one at a time, tmp+rename, and a file that does not parse is refused, never read as `{}`), and a route awaits it before answering: two unserialized plain `writeFile`s tore the file, and the `{}` fallback then erased every other setting. The one direct writer is config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that does not parse, which the writer refuses. Pinned by `test/settings-json-write-serialization.test.ts`.
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
|
||||
@@ -311,7 +319,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></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>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -460,7 +461,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions.
|
||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
|
||||
@@ -479,6 +480,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
|
||||
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||
- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
|
||||
- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md)
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones
|
||||
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
|
||||
@@ -584,6 +586,8 @@ loginctl enable-linger $USER
|
||||
|
||||
**Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions.
|
||||
|
||||
The tunnel Codeman manages (App Settings → System → Remote access) refuses to start without a login (`CODEMAN_PASSWORD` or multi-user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` acknowledges the exposure, or you accept the browser's confirm, which it asks before every enable while no login is set. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers only the network bind: it does not let the tunnel start.
|
||||
|
||||
</details>
|
||||
|
||||
### QR Code Authentication
|
||||
@@ -825,12 +829,14 @@ Those `DONE_<task>_<random>` strings are the skill's **split marker** trick, and
|
||||
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 worked flows: claude, DeepSeek Harness and shell workers, fan-out, blocked-worker watch, messaging fan-out. On demand. |
|
||||
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | Full endpoint tables, error codes, per-mode signal table, capacity limits. On demand. |
|
||||
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | Talking to claude workers directly via Claude Code cross-session messaging. On demand. |
|
||||
| [`reference/history.md`](skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. On demand. |
|
||||
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in: bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. On demand. |
|
||||
|
||||
Every recipe in there was verified against a live server, and the comments record the failure modes that were measured rather than guessed.
|
||||
|
||||
#### Two things worth knowing
|
||||
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
|
||||
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
@@ -971,20 +977,25 @@ codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman attach <path> # show an attachment card for a local file
|
||||
codeman notify "<message>" [--urgent] [--file <path>] # post to this tab's Discord thread (see the Discord bridge)
|
||||
codeman tui --list # numbered session list (plain text when piped)
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||
|
||||
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): auth and ownership apply to every endpoint, including bounded inspection and guarded input. The `inspect`, `stage` and `submit` commands and their HTTP routes are **experimental** and **opt-in**: turn on **Experimental agent coordination** in App Settings (`agentCoordinationEnabled`, synced, off by default; until then the routes answer `403 FORBIDDEN` naming it). Their commands, flags, paths, payloads and behavior may change or be removed in minor or patch releases, so pin a compatible version when automating them; see [Experimental agent coordination](docs/versioning-policy.md#experimental-agent-coordination). One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json # recent evidence + opaque cursor (pane snapshots are labeled)
|
||||
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # no Enter; known local Codex composer only
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # one conditional Enter
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
@@ -994,11 +1005,23 @@ codeman agent ack <msg-id> # remove what you hand
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `spawn` with no case, or with `--working-dir`, starts the worker in that existing directory through `POST /api/v1/sessions` plus `POST /api/v1/sessions/:id/interactive` (or `/shell`) instead of quick-start, so no case is created and the directory is never labelled agent scratch; an empty `--working-dir` refuses (exit `4`) rather than falling back to cwd, and so does a cwd that is your home directory (Codeman's hooks would land in `~/.claude`), which needs an explicit `--working-dir ~`; if the start fails it still prints the created id and exits `1`. `--json` prints the envelope's `data` for every verb, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### `codeman history read`: saved Codex conversations, offline
|
||||
|
||||
`codeman history read` reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else `~/.codex/sessions`) straight from disk: no server, no managed session, nothing written. Unlike `codeman agent` it works anywhere, including on a machine where Codeman is not running.
|
||||
|
||||
```bash
|
||||
codeman history read --session-id 019fc604 # one conversation, by native id or 8+ character prefix
|
||||
codeman history read --root-session-id <uuid> --topic auth # a conversation and every sub-agent or fork under it
|
||||
codeman history read --file /path/to/rollout.jsonl --format text
|
||||
```
|
||||
|
||||
Filters keep whole user/assistant turns (`--topic` is a literal, case-insensitive match; `--after`/`--before` are inclusive UTC dates). A forked or spawned thread's copy of its parent's history is dropped, so every record belongs to the thread that wrote it. Output is JSON by default and carries `complete: false` whenever the evidence is partial (a file that could not be attributed, a compressed `.jsonl.zst` rollout, a record a running Codex is still writing, an unknown ancestor). Every bound fails closed and is raisable: `--metadata-bytes` (attributing the store, default 1 GiB), `--max-entries` (default 65536), `--scan-bytes` (the selected transcripts, default 16 MiB) and `--max-bytes` (output, default 8192). The full reference is [`skills/codeman/reference/history.md`](skills/codeman/reference/history.md).
|
||||
|
||||
### MCP server (`codeman mcp`): drive Codeman from any MCP client
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API. The MCP server adds no routes of its own, and auth and ownership are unchanged.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
@@ -1227,7 +1250,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length.
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
+8
-3
@@ -21,6 +21,7 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></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>
|
||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -585,6 +586,8 @@ loginctl enable-linger $USER
|
||||
|
||||
通过隧道暴露前**务必设置 `CODEMAN_PASSWORD`** —— 否则任何拿到 URL 的人都能完全访问你的会话。
|
||||
|
||||
Codeman 自己管理的隧道(App Settings → System → Remote access)在没有登录(`CODEMAN_PASSWORD` 或多用户模式)时拒绝启动,除非用 `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` 或 `codeman web --allow-unauthenticated-tunnel` 确认这种暴露,或者你在浏览器的确认框里点了同意;没有登录时,每次开启前浏览器都会先问。`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` 只管网络绑定,并不能让隧道启动。
|
||||
|
||||
</details>
|
||||
|
||||
### 二维码认证
|
||||
@@ -826,12 +829,14 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
| [`reference/recipes.md`](skills/codeman/reference/recipes.md) | 8 个完整流程:claude、DeepSeek Harness 与 shell 工作会话、扇出、盯住被卡住的工作会话、消息扇出。按需加载。 |
|
||||
| [`reference/endpoints.md`](skills/codeman/reference/endpoints.md) | 完整端点表、错误码、各模式的信号表、容量限制。按需加载。 |
|
||||
| [`reference/messaging.md`](skills/codeman/reference/messaging.md) | 通过 Claude Code 跨会话消息直接和 claude 工作会话对话。按需加载。 |
|
||||
| [`reference/history.md`](skills/codeman/reference/history.md) | 用 `codeman history read` 离线读取已保存的 Codex 对话:选择器、过滤条件、分叉、预算。按需加载。 |
|
||||
| [`reference/inspection-and-staging.md`](skills/codeman/reference/inspection-and-staging.md) | 实验性功能,需要手动开启:用 `codeman agent inspect`、`stage` 和 `submit` 做有界的窗格检查和受保护的提交。按需加载。 |
|
||||
|
||||
里面的每一个配方都在真实服务器上验证过,注释记录的是实测出来而不是猜出来的失败模式。
|
||||
|
||||
#### 两件值得知道的事
|
||||
|
||||
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
@@ -993,7 +998,7 @@ codeman agent rm "$SID" # 除当前会话外
|
||||
|
||||
### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman
|
||||
|
||||
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。
|
||||
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端。MCP 服务器自己不新增任何路由,认证和归属规则不变。
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
@@ -1221,7 +1226,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/wrapped-file-paths.browser.test.ts',
|
||||
'test/command-palette-agent-arrows.browser.test.ts',
|
||||
'test/tmux-capture-sgr.browser.test.ts',
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
@@ -27,6 +29,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-focus.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/header-divider.browser.test.ts',
|
||||
'test/tab-strip-corner.browser.test.ts',
|
||||
'test/header-host-sensors.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -47,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
@@ -55,6 +59,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
'test/terminal-touch-swipe-axis.browser.test.ts',
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+256
-11
@@ -426,6 +426,13 @@ 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
|
||||
this endpoint.
|
||||
|
||||
Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT`
|
||||
with nothing written while an experimental guarded submit holds the session's
|
||||
input lease, or input held behind it is still being written (a few milliseconds;
|
||||
see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)).
|
||||
Retry it. Untagged input is held instead and written in arrival order once the
|
||||
lease is released.
|
||||
|
||||
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
|
||||
@@ -589,9 +596,167 @@ request returns. The capture runs synchronous tmux calls on the server; the
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
|
||||
|
||||
## Experimental bounded agent inspection and guarded input
|
||||
|
||||
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
|
||||
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
|
||||
are labeled experimental, so the versioning policy's rule for experimental
|
||||
surfaces applies (`docs/versioning-policy.md`, "Experimental agent
|
||||
coordination"): they are not part of the stable contract. Their commands, flags, paths,
|
||||
payloads and behavior may change or be removed in a minor or patch release.
|
||||
Pin a compatible Codeman version when automating them. Authentication and
|
||||
session ownership checks still apply.
|
||||
|
||||
**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced
|
||||
`agentCoordinationEnabled` setting is off (the default; App Settings,
|
||||
Cross-session features, **Experimental agent coordination**), with a message
|
||||
naming it. The check runs before session lookup and body validation, and reads
|
||||
the saved setting on every request, so turning it on needs no restart.
|
||||
`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1).
|
||||
|
||||
These authenticated routes use the standard envelope and session ownership checks.
|
||||
`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before
|
||||
any transcript, pane or receipt lookup.
|
||||
|
||||
| Endpoint | Parameters/body | Result |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` |
|
||||
| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter |
|
||||
| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter |
|
||||
|
||||
Inspection initially supports native local Codex JSONL with strict conversation
|
||||
attribution, using the session's effective `CODEX_HOME`, explicit resume identity
|
||||
or unique managed originator. It never selects an arbitrary newest cwd match.
|
||||
The initial read selects recent complete records; `omittedBeforeBytes` explicitly
|
||||
labels its tail omission. Cursor reads consume only complete appended records,
|
||||
keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON
|
||||
escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution
|
||||
separately caps at 8192 directory entries, eight directory levels, 8 MiB of
|
||||
negative-filter prefix reads and 1 MiB of candidate metadata reads (including
|
||||
final identity verification). The negative filter reads 1 KiB of each rollout
|
||||
first and 4 KiB only when that window holds no complete `originator` field. Once
|
||||
a rollout is attributed, later calls re-verify only that file (same file identity,
|
||||
same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not
|
||||
older than its own, for a second candidate such as a codex `/new`. A file that no
|
||||
longer verifies falls back to one full walk; a verified file whose incremental scan
|
||||
finds a second candidate or runs out of budget answers unattributed without one,
|
||||
since the full walk reads a superset of that scan. So the caps bound the first call,
|
||||
not every call.
|
||||
An unexpected read error is `422 OPERATION_FAILED` (logged with the session id
|
||||
and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories
|
||||
are probed with a bounded past-cap probe before canonical reads and remain
|
||||
unattributed. A full incremental page can be continued with its cursor; only a
|
||||
first pending record too large for the budget reports an output-budget block.
|
||||
Oversized/unfinished records report `blockedReason`; incremental
|
||||
reads do not silently skip them. Other modes, remote/container sessions, and
|
||||
unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`,
|
||||
`cursor:null`; these snapshots can repeat evidence.
|
||||
|
||||
Attribution identifies a file, not an authoritative current-thread signal. A
|
||||
configured launch resume and a new owned rollout conflict and force fallback.
|
||||
An in-CLI `/resume` of an existing foreign-originator thread may leave no new
|
||||
owned rollout; launch metadata cannot detect that switch. Check the returned
|
||||
conversation identity and do not use these observations as proof of the active
|
||||
thread, task completion or authorization.
|
||||
|
||||
Opaque server-side cursors bind caller, session, pane generation, conversation,
|
||||
opened-file identity, byte offset and bounded content anchors. Terminal resizes
|
||||
do not change this inspection scope. Each owner and session keeps its newest 8
|
||||
cursors (a retry of the last call still works; older ones are retired), at most
|
||||
1024 live cursors are retained in all, each for 15 minutes; restart, eviction or
|
||||
expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign
|
||||
cursors or loss of the attributed source return `409 CONFLICT`. No transcript
|
||||
filesystem path is returned as cursor/identity metadata; evidence retains the
|
||||
owner's native record content, which can itself contain paths.
|
||||
|
||||
Guarded input initially requires a local characterized Codex composer, one
|
||||
unwrapped visible line, a recognized footer and a cursor at its exact end. The
|
||||
composer glyph and footer are the CLI registry's
|
||||
`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that
|
||||
declares no `composerFooterLine` is refused.
|
||||
Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts
|
||||
at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing
|
||||
whitespace (the existing mux transport trims it). Before typing, it checks the
|
||||
actual captured pane width and rendered composer indentation, refusing any
|
||||
message that would reach the right edge or wrap. Every declared Codex effort
|
||||
word plus `default` is recognized. Its two-minute receipt stores
|
||||
only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts),
|
||||
never persisted prompt content.
|
||||
|
||||
Stage refuses, before typing anything, a message that would change what Enter
|
||||
does rather than what it sends, from the registry's
|
||||
`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message
|
||||
starting with `/` (a slash command; codex runs it, or with its command list open
|
||||
runs the highlighted one) or `!` (a shell command), checked after any leading
|
||||
whitespace, and a message whose last word starts with `@` (file and mention
|
||||
search) or `$` (skills; every `$` word, since whether codex opens that list
|
||||
depends on its skill catalog). Those popups follow the caret, which stage leaves
|
||||
at the end of the message, so a `@` or `$` word earlier in the message is
|
||||
accepted. Send slash and shell commands through `POST .../input`
|
||||
(`codeman agent send`). Independently, a frame with a suggestion popup in the
|
||||
block directly above the composer (the registry's `composerPopupLine`) is refused
|
||||
at stage and submit, since codex draws every popup there and leaves the footer
|
||||
below the composer unchanged.
|
||||
|
||||
A receipt is issued only for a capture that shows exactly the message with the
|
||||
caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive,
|
||||
so after typing stage re-reads the pane every 50 ms for up to one second and
|
||||
takes the first frame with the same pane generation, the input revision its own
|
||||
write moved and a composer whose SHA-256 equals the message's. Each capture
|
||||
reads the screen and the caret in one tmux invocation. A frame showing another
|
||||
writer's input, a new pane lifecycle or a new geometry ends the wait at once.
|
||||
Every refusal after the keys went out (typing failed or was ambiguous, the
|
||||
pane changed, or no frame verified in time) is a `409` that says the message
|
||||
may already be in the composer: inspect the pane and follow the stranded-draft
|
||||
recovery (Ctrl+U) in the skill reference before staging again.
|
||||
|
||||
While a guarded submit holds the input lease (the few milliseconds of its
|
||||
conditional Enter), input that reaches the session is held, not refused, and is
|
||||
written in arrival order as soon as the lease is released, whatever the submit's
|
||||
outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged
|
||||
only once it is written, so a frame typed during the lease lands before the one
|
||||
typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval
|
||||
answers and Codeman's own managed writes. A held write that is still being sent
|
||||
holds everything behind it, with one exception: when the hold queue is full (256
|
||||
inputs or 1 MiB), a managed write (`writeViaMux`, which `POST .../input` uses for a plain
|
||||
prompt or `useMux`) is not held and that route falls back to a direct write, which
|
||||
is refused during the lease itself but can land ahead of input still being written
|
||||
after it. A tagged `POST /api/v1/sessions/:id/input` (one
|
||||
carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit
|
||||
is in progress on this session; nothing was written") for that span, the lease
|
||||
plus the held input's drain, rather than a `200` for bytes that were never
|
||||
written; it keeps its seq, so the browser's durable queue, whose POST fallback
|
||||
sends one frame at a time and keeps it on a `409`, delivers it on the next retry.
|
||||
Untagged input to that route is held like a WebSocket frame, because its senders
|
||||
(the phone accessory keys, the Stop button) never read the answer. A lease holds
|
||||
at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and
|
||||
nothing later from it is applied, so the browser reconnects and resends every
|
||||
unacknowledged frame in order, and `send-key` and an untagged raw input answer
|
||||
`409 CONFLICT`.
|
||||
|
||||
Direct auxiliary key operations participate in the same pending-write and input
|
||||
lease checks. Complete allowlisted focus/mouse reports and empty writes preserve
|
||||
input revision; mixed or unknown sequences remain keyboard-bearing. Exact
|
||||
composer/caret checks also reject a mouse-moved caret.
|
||||
|
||||
Submit consumes an owned receipt before awaited delivery, verifies the caller,
|
||||
confirmation hash and expiry, then waits until at least 250 ms have passed since
|
||||
the staged keys went out (codex reads keys typed in one burst as a paste and, for
|
||||
120 ms after one, turns Enter into a newline instead of a submit), then verifies
|
||||
lifecycle/input/geometry revisions and exact composer/caret,
|
||||
then uses a session-level conditional Enter dispatch with a temporary input lease.
|
||||
Competing Codeman writes cannot enter during that dispatch. Managed restarts and
|
||||
intervening browser/API/auxiliary input invalidate receipts independently of pid
|
||||
reuse or restored visible text. Direct tmux edits remain outside Codeman's
|
||||
serialization. Failed, ambiguous, expired, changed or repeated submits return
|
||||
`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its
|
||||
error recovery automatically replays Enter. Success acknowledges delivery of one
|
||||
Enter, not native acceptance or task completion. Invalid schema input returns
|
||||
`400 INVALID_INPUT`.
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`, and adds no route for the other verbs: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
|
||||
|
||||
@@ -735,6 +900,16 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
|
||||
keeps the server from storing what it would serve or an agent could run, and
|
||||
html and svg are the ones deliberately kept out.
|
||||
|
||||
## New folder in the path picker
|
||||
|
||||
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
|
||||
folder directly inside `path`, which must be a folder the path picker may browse, and
|
||||
answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not
|
||||
starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path`
|
||||
that is not a directory, `403` for a blocked tree or a `path` outside the browse roots,
|
||||
`404` for a `path` that does not exist, `409` when the folder already exists, and `422`
|
||||
when the mount under `path` does not answer.
|
||||
|
||||
## Rendered HTML files
|
||||
|
||||
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
|
||||
@@ -804,6 +979,18 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||
unresolved) and survives a server restart.
|
||||
|
||||
## Pane directory (`paneCwd`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`,
|
||||
the directory the session's local tmux pane is in now (`#{pane_current_path}`), which
|
||||
the web UI uses as the base for a relative path printed in the terminal. It is
|
||||
never restored after a restart (it is written to `state.json` with the rest of the
|
||||
session state but not read back), absent for remote,
|
||||
Docker, direct-PTY and rediscovered sessions and for a session split into several panes,
|
||||
and never a confinement root: file routes stay confined to `workingDir`.
|
||||
tmux reports the resolved directory, so for a workspace reached through a symlink a
|
||||
directory at or under the resolved workspace is reported in `workingDir`'s spelling.
|
||||
|
||||
## Session model (`displayModel`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||
@@ -1040,24 +1227,47 @@ data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
|
||||
boolean` reports whether one is stored, so a client can render "unchanged
|
||||
if left blank" without ever holding the real value.
|
||||
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
|
||||
authStyle?, defaultModelId? }` creates one. `id` must match
|
||||
authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id`
|
||||
must match
|
||||
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
|
||||
both (a real server hung indefinitely when sent both headers on one
|
||||
request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
|
||||
request); `defaultContextLength` is a user-set fallback used when
|
||||
assigning a model that discovery has no context length for (discovery
|
||||
itself never writes it); `baseUrl` must be `http(s)`, carry no embedded
|
||||
credentials, and
|
||||
is refused if it points at (or resolves to) a link-local or
|
||||
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one, also accepting
|
||||
`defaultContextLength`. An **absent** `apiKey`
|
||||
keeps the stored one rather than clearing it — the client never receives
|
||||
the real value to resend deliberately unchanged, so omission is the only
|
||||
way to say "leave it alone"; there is no way to clear a key back to unset
|
||||
this way. `defaultModelId`, when set, must be one of that endpoint's own
|
||||
this way. `defaultContextLength` behaves the opposite way: a PUT without
|
||||
it clears the stored value, so send the field back to keep it.
|
||||
`defaultModelId`, when set, must be one of that endpoint's own
|
||||
`models` (`400 INVALID_INPUT` otherwise).
|
||||
- `DELETE /api/v1/model-endpoints/:id` removes one.
|
||||
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
|
||||
own `GET /v1/models` and stores the result as `models`, updating
|
||||
`lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own
|
||||
response already reports loaded) `modelContextLengths` and `modelSizesGB`.
|
||||
A `defaultModelId` that no longer appears in the fresh list is dropped
|
||||
`lastDiscoveredAt`, plus (best-effort) `modelContextLengths`,
|
||||
`modelSlotCounts` and `modelSizesGB`: every listing entry is read for a
|
||||
stated context figure (`context_length`, then `context_window`) and a
|
||||
size, BEFORE any loaded check; only the launch-command/`/props`
|
||||
enrichment needs a model llama-swap's own response reports loaded. A
|
||||
loaded model's context figure comes from llama-swap's own launch command
|
||||
(`--fit-ctx` / `-c` / `--ctx-size`), falling back to `/props` (`n_ctx`,
|
||||
`total_slots`) for whatever the command did not answer, and OVERWRITES the
|
||||
listed figure for that model; the model's own listing
|
||||
(`context_length`, then `context_window`) only ever fills the gap when
|
||||
neither probe does, and is recorded as a single-slot figure:
|
||||
llama-swap lists the PER-SLOT window, so whenever the listed figure is
|
||||
the one taken the stored slot count is 1. A server with no `status`
|
||||
field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no
|
||||
launch-command/`/props` enrichment at all, only whatever its listings
|
||||
state. `modelContextLengths` falls back to the
|
||||
endpoint's `defaultContextLength` when a session is assigned a model
|
||||
discovery never learned a window for. A `defaultModelId` that no longer
|
||||
appears in the fresh list is dropped
|
||||
rather than carried forward invalid. Failures answer `422 OPERATION_FAILED`
|
||||
with the underlying connection error, or a named egress refusal if the
|
||||
resolved address turned out to be blocked. The same refresh also runs
|
||||
@@ -1150,9 +1360,11 @@ The target is judged before anything is written:
|
||||
|
||||
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||
|
||||
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing.
|
||||
|
||||
## Git status
|
||||
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch` (the panel's Fetch button is a separate route, below). The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||
|
||||
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
|
||||
|
||||
@@ -1175,6 +1387,25 @@ Both routes accept two optional query parameters, which the UI sends from its pe
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
|
||||
|
||||
`data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought.
|
||||
|
||||
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
|
||||
|
||||
|
||||
## Edit history
|
||||
|
||||
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
|
||||
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
|
||||
`POST /api/sessions/:id/edits/older` reads the next older block, within the current conversation, of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list.
|
||||
|
||||
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
|
||||
@@ -1213,12 +1444,13 @@ A local case whose folder is the top of a git repository can keep **worktrees**
|
||||
|
||||
## Settings
|
||||
|
||||
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected.
|
||||
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ----------------------------- | ------------------------------------ | ----- |
|
||||
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
|
||||
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). |
|
||||
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
|
||||
| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. |
|
||||
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
|
||||
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
|
||||
| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. |
|
||||
@@ -1314,6 +1546,19 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
|
||||
|
||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||
|
||||
## Discord bridge
|
||||
|
||||
One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ------ | ------ | ------ |
|
||||
| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. |
|
||||
| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. |
|
||||
| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. |
|
||||
| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, not on the attachment blocklist, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. |
|
||||
|
||||
Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
+161
-18
@@ -53,7 +53,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
||||
|
||||
⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.
|
||||
|
||||
⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`.
|
||||
⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder (one-shot AND streaming) before 24.22 and 26.10 (nodejs/node#65865) stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`.
|
||||
|
||||
⚠️ **This is also what makes dsh the one non-claude mode the `codeman` agent skill drives like claude** (`skills/codeman/preamble.sh`, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, `spawn_workers alpha beta:deepseek` is a mixed fleet in one call and `sendwait`/`last_text` need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. **Readiness is not the stop signal**: the harness reports `idle` at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after `quick-start` resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — `spawn_worker`'s dsh branch gates on the composer glyph (`❯`, overridable via `DSH_READY_MARK`) instead, after which the boot edge is spent and unobservable. And `sendwait` asks for `wait:"stop,exit"` rather than the `wait:true` default set, because that set also carries `idle`, which for any external CLI is inferred from output stabilization: on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn that had three minutes left to run. The skill also sends `deepSeekConfig.permissionMode: 'danger-full-access'` for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies).
|
||||
|
||||
@@ -81,13 +81,13 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
|
||||
|
||||
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
|
||||
|
||||
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
|
||||
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside theLine truncated
|
||||
|
||||
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
|
||||
|
||||
@@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
|
||||
|
||||
⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug.
|
||||
|
||||
⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
|
||||
⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
|
||||
|
||||
⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
|
||||
|
||||
@@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
|
||||
|
||||
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
|
||||
|
||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
|
||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
|
||||
|
||||
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
|
||||
|
||||
@@ -345,8 +345,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
|
||||
- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown).
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||
@@ -409,7 +409,17 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
|
||||
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
|
||||
|
||||
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
|
||||
|
||||
Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole.
|
||||
|
||||
Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess.
|
||||
|
||||
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
|
||||
|
||||
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
|
||||
|
||||
@@ -425,7 +435,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
|
||||
|
||||
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
|
||||
|
||||
@@ -464,11 +474,13 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
|
||||
|
||||
### Clone a repository as a case
|
||||
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
|
||||
|
||||
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
|
||||
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
|
||||
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
|
||||
- ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it.
|
||||
- ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches.
|
||||
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
|
||||
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
|
||||
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
|
||||
@@ -624,17 +636,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
|
||||
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
|
||||
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
|
||||
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
|
||||
|
||||
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
|
||||
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
|
||||
|
||||
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
|
||||
|
||||
@@ -738,6 +750,24 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
### Edit history panel
|
||||
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
|
||||
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending.
|
||||
|
||||
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write.
|
||||
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
|
||||
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
|
||||
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
|
||||
### Config backups
|
||||
|
||||
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
|
||||
@@ -750,7 +780,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again.
|
||||
|
||||
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
|
||||
⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened.
|
||||
|
||||
⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock.
|
||||
|
||||
@@ -813,7 +843,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by
|
||||
|
||||
### Terminal paste (Ctrl+V)
|
||||
|
||||
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both:
|
||||
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both:
|
||||
|
||||
1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing.
|
||||
2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well.
|
||||
@@ -1025,6 +1055,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
|
||||
|
||||
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
|
||||
|
||||
⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted.
|
||||
|
||||
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
### Tab layouts (by state, by case, ledger)
|
||||
@@ -1041,6 +1073,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
|
||||
|
||||
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||
|
||||
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
|
||||
|
||||
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||
|
||||
### Header stats styles
|
||||
@@ -1151,7 +1185,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### PTY and browser terminal geometry
|
||||
|
||||
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it).Line truncated
|
||||
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px Line truncated
|
||||
|
||||
**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes.
|
||||
|
||||
### Terminal resilience: replay clears, renderer liveness, fetch deadlines
|
||||
|
||||
@@ -1182,7 +1218,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
@@ -1260,3 +1296,110 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers
|
||||
⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see.
|
||||
|
||||
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
|
||||
|
||||
## Agent inspection and guarded submission
|
||||
|
||||
These three operations are experimental across both CLI and HTTP, including the
|
||||
`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule
|
||||
in `docs/versioning-policy.md`, so do not infer stability from the URL prefix.
|
||||
|
||||
⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming
|
||||
`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session
|
||||
features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The
|
||||
gate runs first, before session lookup or body validation, and reads
|
||||
`settings.json` fresh on every request, so a toggle applies without a restart.
|
||||
`codeman agent inspect|stage|submit` pass the 403 through verbatim.
|
||||
|
||||
`agent-session-routes.ts` is registered by `WebServer` with the other route
|
||||
modules. Ownership is checked before transcript/pane/receipt lookup. Inspection
|
||||
uses strict native metadata attribution, bounded traversal, separate prefix and
|
||||
candidate-metadata budgets, bounded path probes before canonical reads, and
|
||||
complete UTF8 JSONL records. A full page advances its cursor without claiming a
|
||||
blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane
|
||||
geometry is excluded so changing devices does not invalidate transcript paging.
|
||||
Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8,
|
||||
under the global 1,024), so one polling agent retires its own oldest cursors instead
|
||||
of evicting every other caller's from the shared FIFO.
|
||||
|
||||
⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store
|
||||
walk per call. The first call walks (negative-filter prefixes read 1 KiB first,
|
||||
4 KiB only when that window holds no complete `originator` field; non-UUID file
|
||||
names are skipped unread), then the attributed `{path, dev:ino}` and its strict
|
||||
metadata are remembered per session (`attributionMemo`, bounded LRU). A later call
|
||||
re-verifies only that file's head, then scans just the date directories not older
|
||||
than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is
|
||||
never pruned) for a second candidate, which is how a codex `/new` or a new owned
|
||||
rollout beside a configured resume is still caught. A head that no longer verifies
|
||||
drops the memo and falls back to one full walk; a verified head whose incremental
|
||||
scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it
|
||||
and answers unattributed without one, because the full walk reads a superset of that
|
||||
scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422
|
||||
OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and
|
||||
error code only, never the path.
|
||||
|
||||
Staging requires a fresh, known composer/caret capture. The composer glyph and
|
||||
footer are registry data (`capabilities.workDetect.promptGlyph` and
|
||||
`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI
|
||||
declaring `composerFooterLine` takes guarded input (codex today). Codex's footer
|
||||
words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available
|
||||
columns are calculated from actual captured geometry and the rendered prefix,
|
||||
including indentation; reaching the last column or wrapping is refused. A message
|
||||
starting with a `composerCommandPrefixes` character or whose last word starts with a
|
||||
`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a
|
||||
frame with a `composerPopupLine` row in the block just above the composer is refused at
|
||||
stage and submit: codex draws its popups there and keeps its footer, so the footer
|
||||
alone read `› /stat` with the command list open as idle, and Enter ran `/status`.
|
||||
⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS`
|
||||
50) for the first frame with the same generation, `revision === before + 1` and
|
||||
`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys
|
||||
and the first capture is often from before them; another writer or a lifecycle change
|
||||
ends the wait at once, and every refusal after typing says the message may be in the
|
||||
composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call
|
||||
(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to
|
||||
the two calls only for output it cannot split; a combined call that THREW goes to the
|
||||
capture's catch and is never rerun (a hung tmux would block the event loop for a second
|
||||
timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives
|
||||
while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`,
|
||||
`_inGuardedTurn()`), and written in arrival order when the lease is released: the
|
||||
browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by
|
||||
N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written;
|
||||
a held async write (mux write, named key) holds everything behind it until it settles
|
||||
(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with
|
||||
`1013` and applies nothing later from it, so the browser resends in order. `POST
|
||||
.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain,
|
||||
since its browser drainer is serialized and retries; untagged input there (the phone
|
||||
accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the
|
||||
answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write
|
||||
by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue
|
||||
it again behind later input and wait on itself). `write()` refuses during the lease
|
||||
only, since the drain writes held input through it, so a direct `write()` caller such
|
||||
as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining.
|
||||
⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its
|
||||
checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and
|
||||
an Enter within 120 ms of one becomes a newline while submit would report it
|
||||
delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and
|
||||
bounded ephemeral identities only, expire, and are consumed before awaited
|
||||
submission, including failures.
|
||||
|
||||
Submission additionally binds input and geometry revisions and rechecks the
|
||||
exact composer/caret at the conditional Enter boundary. The route must retain
|
||||
the Session snapshot's staging generation rather than replacing it with the
|
||||
inspection generation. Managed input and auxiliary named keys participate in the
|
||||
same pending/lease boundary: they wait for the lease, and only a full hold queue
|
||||
refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are
|
||||
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
|
||||
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
|
||||
updates `lastSubmitAt` just like any other Enter.
|
||||
|
||||
## Discord bridge
|
||||
|
||||
`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md).
|
||||
|
||||
- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`.
|
||||
- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state.
|
||||
- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false.
|
||||
- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded.
|
||||
- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires.
|
||||
- **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first.
|
||||
- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop.
|
||||
- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`).
|
||||
+33
-3
@@ -47,9 +47,12 @@ interface CliEntry {
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
|
||||
// blockedLine?, blockedLines? }
|
||||
// blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?,
|
||||
// composerCommandPrefixes?, composerCompletionSigils? }
|
||||
// (how this CLI's pane shows work, work it started in the background, a turn
|
||||
// that ended waiting for workers it will resume from, and a dialog it is blocked on)
|
||||
// that ended waiting for workers it will resume from, a dialog it is blocked on,
|
||||
// the rows around its idle composer that guarded input relies on, and what a
|
||||
// draft can trigger)
|
||||
// .modelDetect?: { screenLine, screenLines? }
|
||||
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||
// .launchDefaults?: { [launchParam]: settingsKey }
|
||||
@@ -63,7 +66,7 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight 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.
|
||||
|
||||
@@ -124,6 +127,33 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f
|
||||
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
|
||||
pattern, make sure its row is one the agent cannot write.
|
||||
|
||||
### The idle composer guarded input reads (`composerFooterLine`)
|
||||
|
||||
The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through
|
||||
`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture,
|
||||
that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the
|
||||
exact end of its text. `composerFooterLine` is the second half of that proof: every
|
||||
non-blank row below the composer (at most three) must match it, one row at a time. A CLI
|
||||
that declares none never takes guarded input; only codex declares one, built from the same
|
||||
effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a
|
||||
codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the
|
||||
foot of the screen, so a pattern must anchor on chrome only that CLI draws there.
|
||||
|
||||
Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup
|
||||
(its slash-command list, file search, skill and mention lists) ABOVE the composer and
|
||||
leaves the footer below it alone, so the footer check reads `› /stat` with the command
|
||||
list open as an idle draft, and Enter would run the highlighted `/status`.
|
||||
`composerPopupLine` matches a popup row; it is tested against the contiguous block of
|
||||
non-blank rows directly above the composer (past at most three blank rows, at most twelve
|
||||
rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists
|
||||
the characters that, first in a draft, make Enter run a command (codex: `/!`), and
|
||||
`composerCompletionSigils` the ones that, starting the word under the caret, open a popup
|
||||
(codex: `@$`); stage refuses such a message before typing, the sigils only on the last
|
||||
word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's
|
||||
values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search
|
||||
popup with results shows bare path rows no pattern can tell from prose, which is why the
|
||||
trigger refusal is there as well as the frame check.
|
||||
|
||||
### Dialogs a CLI blocks on (`blockedLine`)
|
||||
|
||||
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
|
||||
|
||||
@@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's
|
||||
OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway).
|
||||
Right now every Codeman session mode defaults to its native cloud backend
|
||||
with no way to redirect a session at any other endpoint from the UI — the
|
||||
closest existing precedent is DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which isn't user-facing.
|
||||
closest existing precedent was DeepSeek's server-env-sourced
|
||||
`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
|
||||
been superseded by deepseek's per-session patch recipe, see the table
|
||||
below).
|
||||
|
||||
**Scope note**: this plan originally said "local LLM." It now covers any
|
||||
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
|
||||
@@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch.
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
|
||||
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
|
||||
|
||||
+122
-36
@@ -67,15 +67,23 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
|
||||
Endpoint management is admin-only in multi-user mode, same as remote/docker
|
||||
hosts — these are machine-level infra, not per-user settings.
|
||||
|
||||
**Context length is discovered too, opportunistically and safely.** The plain
|
||||
`GET /v1/models` response has no context-window field. Discovery only ever
|
||||
looks for one for a model llama-swap's own response already reports
|
||||
**Context length is discovered too, opportunistically and safely.** Older
|
||||
servers state no context-window field in the plain `GET /v1/models`
|
||||
response at all; current llama-swap states one per model (both
|
||||
`context_length` and `context_window`) and gateways like Bifrost pass
|
||||
`context_window` through: that listed figure is the *lowest*-precedence
|
||||
source, used only when neither probe below answers, and because llama-swap
|
||||
documents it as the PER-SLOT window, a model whose figure comes from the
|
||||
listing is recorded with a slot count of 1 so the slot check never divides a
|
||||
per-slot figure a second time. The probes are only ever looked for on a model
|
||||
llama-swap's own response already reports
|
||||
`status.value === "loaded"` for — never for an unloaded one, because
|
||||
llama-swap treats `?model=` as a routing hint and asking about a model that
|
||||
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
|
||||
effect of what should be read-only discovery. A server with no `status` field
|
||||
on any entry at all (not llama-swap) gets no context-length enrichment,
|
||||
rather than guessing. A model's previously-learned context length survives a
|
||||
on any entry at all (not llama-swap) gets no launch-command/`/props`
|
||||
enrichment, only whatever its listings state, rather than guessing. A
|
||||
model's previously-learned context length survives a
|
||||
later cycle where it wasn't the loaded one; it's dropped only once the model
|
||||
disappears from the endpoint's list entirely. Stored per model in
|
||||
`modelContextLengths` and applied automatically (see "Applying a model to a
|
||||
@@ -192,32 +200,66 @@ saved default, not just confirming it.
|
||||
|
||||
The modal promotes exactly one row to the top of the list rather than
|
||||
always showing raw discovery order, so the zero-wait choice is the one
|
||||
under your thumb:
|
||||
under your thumb. Three kinds of knowledge feed that promotion, best
|
||||
evidence first, and the first one that answers wins:
|
||||
|
||||
- **"Currently loaded"** — a model from this host's own list that
|
||||
- **"Currently loaded"** is a model from this host's own list that
|
||||
llama-swap reports `ready` right now, queried via
|
||||
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
|
||||
~800ms (`Promise.race`), on top of the route's own 5s server-side
|
||||
timeout, so an endpoint that is asleep or firewalled cannot leave the
|
||||
modal invisible for the full 5s after the Run menu has already closed.
|
||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||
actually launched last for this exact (harness, endpoint) pair, read
|
||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
- **"In use by a session"** is the fallback for every endpoint that
|
||||
cannot report its own loaded model, which is every server except
|
||||
llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as
|
||||
Bifrost fronting a whole fleet all answer `isLlamaSwap: false`.
|
||||
`_getCustomModelInUseBySession` walks `this.sessions` for one of your own
|
||||
sessions whose `customModel.endpointId` is this host, newest
|
||||
`createdAt` wins, and promotes its model. A live session is first-hand
|
||||
evidence of a model in use on the endpoint, because Codeman put it
|
||||
there, so it earns the promotion and the tag says where the knowledge
|
||||
came from. Two fences keep it honest: it reads only `customModel`,
|
||||
never `displayModel` (display text scraped off a CLI footer, so a
|
||||
terminal that merely printed a similar string must not mark a row),
|
||||
and a session with `status: 'stopped'`, a null `pid` (its PTY exited,
|
||||
the same live-pane test keep-warm applies) or a recorded `paneExit`
|
||||
does not count, since nothing is on the endpoint any more. Sessions on other
|
||||
endpoints, and models the host no longer discovers, are skipped.
|
||||
- **"Last used"** appears only when neither of the above answers: the
|
||||
model actually launched last for this exact (harness, endpoint) pair,
|
||||
read from the per-device localStorage key
|
||||
`codeman:customModelLastUsed:<mode>:<endpointId>`. That key is per CLI
|
||||
on purpose, so it can only
|
||||
ever promote the CLI that launched there before, which is why a gateway
|
||||
box with no session running shows plain discovery order for the other
|
||||
CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and
|
||||
`_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
`runCustomModelEntry` entry point itself only dispatches between the
|
||||
two) only once the model is actually applied, never on the mere click —
|
||||
two) only once the model is actually applied, never on the mere click:
|
||||
declining the context-window warning means this exact model cannot work
|
||||
with this CLI at all, so promoting it next time would be actively wrong,
|
||||
not just premature.
|
||||
with this CLI at all, so promoting it next time would be actively
|
||||
wrong, not just premature.
|
||||
|
||||
Neither tag reorders anything past that one promoted row. The "Default"
|
||||
No promotion reorders anything past that one promoted row. The "Default"
|
||||
pill is a separate span, not a third value of the same slot: a promoted
|
||||
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||
single-purpose GPU box that is the common case, and an exclusive slot
|
||||
silently dropped the Default marking for exactly that row), and a row
|
||||
with neither promotion nor default shows no tag at all.
|
||||
|
||||
When nothing is known about any row, `#customModelPickUnknown` says so
|
||||
out loud instead of leaving an alphabetical wall that reads as the app
|
||||
ignoring the question. It shows when the probe answered
|
||||
`isLlamaSwap: false`, which the route gives for every failure it
|
||||
catches, so one answer covers a server that cannot report and an
|
||||
endpoint that is down or refused alike; the line's wording therefore
|
||||
claims only that nothing was reported. A probe that never got any
|
||||
answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own
|
||||
request failed or the client-side bound won) keeps the line hidden.
|
||||
Like its sibling no-matches line it needs an
|
||||
explicit `[hidden]` rule scoped to `#customModelPickModal`, because
|
||||
`.form-hint`'s `display: block` outranks the UA `[hidden]` rule.
|
||||
|
||||
**How the launch itself applies the endpoint depends on the harness.** For
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
|
||||
@@ -286,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume <conversation> ||
|
||||
grok are relaunched with the `--model` value that selects the injected
|
||||
provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok),
|
||||
since for those three the config file alone does not switch the model.
|
||||
**Codex is relaunched with `--model <modelId>` too**, the bare selected id
|
||||
(not a provider selector): the launch param forces the session onto the
|
||||
model its generated `config.toml` already selected, so a launch-time model
|
||||
carried over from App Settings can never override the injected selection.
|
||||
That launch arg must satisfy the codex entry's `model-plain-colon` token
|
||||
pattern (a leading letter or digit, then letters, digits and `._-:/`, per
|
||||
`stock.ts`), so an id outside that shape, e.g. LM Studio's
|
||||
`org/model@q4_k_m`, is refused with a 400 at apply time now, where a
|
||||
`config.toml` alone used to carry it.
|
||||
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
|
||||
reattaches the durable remote/in-container tmux rather than relaunching the
|
||||
agent, so the selection would report success and change nothing.
|
||||
@@ -293,14 +344,16 @@ agent, so the selection would report success and change nothing.
|
||||
**Claude gets two more env vars when known/applicable, both declared on its
|
||||
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
|
||||
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
|
||||
length (see the discovery section above) whenever one is known. Without
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
|
||||
the discovered figure (see the discovery section above), falling back to
|
||||
the endpoint's user-set `defaultContextLength`, whenever one is known.
|
||||
Without
|
||||
it, Claude Code assumes a large (200k) window for any unrecognized custom
|
||||
model id and never compacts, which reliably overflows a much smaller real
|
||||
local context — confirmed live: a stock ~33.7K-token system prompt against
|
||||
a 16384-token llama-swap model failed with `exceeds the available context
|
||||
size`. No entry for the model in `modelContextLengths` means the var is
|
||||
simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
|
||||
for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
Code compacts conversation _history_ — it cannot fix a model whose real
|
||||
context is smaller than Claude Code's own fixed per-turn overhead**
|
||||
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
|
||||
@@ -441,15 +494,17 @@ Both apply routes (the restart route and the one-shot `POST
|
||||
/api/quick-start`) now check for this **before** launching or restarting
|
||||
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
|
||||
(currently only claude — the check is a no-op for every other CLI by
|
||||
construction, never a hardcoded mode check). If the model's discovered
|
||||
context (`modelContextLengths`, from discovery above) is below
|
||||
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
construction, never a hardcoded mode check). If the model's context (the
|
||||
discovered figure from `modelContextLengths`, or the endpoint's
|
||||
`defaultContextLength` when discovery never learned one for that model) is
|
||||
below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
|
||||
contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is
|
||||
restarted or created yet. A context length that was never discovered at
|
||||
all skips the check entirely (nothing to compare, so it fails open rather
|
||||
than warning on every model an endpoint hasn't reported a size for).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
restarted or created yet. A model with neither a discovered figure nor a
|
||||
`defaultContextLength` skips the check entirely (nothing to compare, so it
|
||||
fails open rather than warning on every model an endpoint hasn't reported a
|
||||
size for).
|
||||
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
|
||||
The Run-menu picker shows this as an in-app modal
|
||||
(`#customModelContextWarningModal`, matching the llama-swap conflict
|
||||
@@ -526,7 +581,12 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
and fabricating a fake catalog entry would mean copying the _shape_ of
|
||||
OpenAI's own proprietary schema (their per-model system-prompt content
|
||||
included) for a warning that doesn't otherwise affect behavior — not
|
||||
something to build into discovery.
|
||||
something to build into discovery. ⚠️ The fallback-metadata warning is
|
||||
NOT about the context window going un-overridden. Codex does now get a
|
||||
real context-window override: when a figure is known the generated
|
||||
`config.toml` sets the top-level `model_context_window` to it and
|
||||
`model_auto_compact_token_limit` to 75% of it (floored), so Codex
|
||||
compacts at that point instead of trusting its fallback guess.
|
||||
- **Gemini** — fails with `Invalid auth method selected`, traced to an
|
||||
undocumented `GATEWAY` auth path gemini-cli selects once
|
||||
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
|
||||
@@ -541,14 +601,40 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
`POST <baseUrl>/chat/completions` 404s against llama-swap while
|
||||
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
|
||||
template (`DeepSeek API error (HTTP ${status})`) matches the originally
|
||||
reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
|
||||
(deepseek's entry only — claude/gemini must NOT get it, since claude was
|
||||
already confirmed working against the raw `baseUrl`) fixes it by writing
|
||||
`DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
|
||||
real `dsh` binary (no install available in this environment) — the fix
|
||||
is source-confirmed and live-verified at the HTTP level, but a real
|
||||
"hello world" reply through `dsh` itself is still outstanding before
|
||||
calling this fully verified like the harnesses above.
|
||||
reported symptom exactly. The fix is no longer the original env redirect:
|
||||
deepseek's `customModelInjection` is a `configDir` recipe: a per-session
|
||||
`DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
|
||||
`codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
|
||||
suffix from the shared `renderConfigFile` step every file template goes
|
||||
through), so the installed profile keeps supplying the transport; the
|
||||
recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
|
||||
`settings.yaml` back out of the real dsh home so installed bundles,
|
||||
transcripts and user settings all survive the redirect (missing
|
||||
directory entries are created first: a home that has never run a
|
||||
turn would otherwise get a dangling link whose every write through
|
||||
it failed with ENOENT). **Verified end-to-end against a real dsh
|
||||
install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
|
||||
`--dump-config` shows the three patched rows, the TUI boots on the
|
||||
chosen model, and a prompt reaches the endpoint as
|
||||
`POST /v1/chat/completions` with the right model, the bearer key and
|
||||
`max_tokens: 8192`; the reply renders and the context window shows in
|
||||
the footer. What such a session SHARES with the real home is
|
||||
exactly the linked entries: `profiles/`, `sessions/`,
|
||||
`settings.yaml`. The last is shared only until the session writes
|
||||
it: dsh saves settings with a temp file and a rename
|
||||
(`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
|
||||
the symlink, so the first `/settings` change inside such a session
|
||||
never reaches `~/.dsh/settings.yaml` and is deleted with the session's
|
||||
directory on clear or close. Everything else in the generated `DSH_HOME` is
|
||||
isolated: `storages/` (dsh-TUI's persisted `/model` choice and
|
||||
workspace state), `.agent-presets` and `.env`. Two consequences of
|
||||
the generated home-level file follow: the user's own home-level
|
||||
`cordis.patch.yml` does NOT apply in such a session (the generated
|
||||
file replaces it; profile-level patch files reached through the
|
||||
linked `profiles/` still compose), and the generated patch REPLACES
|
||||
the `llm-pi-ai` provider row's whole `config` for the session. Note
|
||||
too that it likewise REPLACES the `dsh-tui` row's whole `config`, so
|
||||
a profile-level `effort`/`preset` does not apply.
|
||||
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
|
||||
|
||||
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
|
||||
|
||||
@@ -235,9 +235,11 @@ worker's first answer reads as an answer. Three things about the file shaped the
|
||||
reader (`src/deepseek-transcript.ts`):
|
||||
|
||||
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
|
||||
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
|
||||
transcript came back as 1 line. The reader walks frame headers itself. On a Node
|
||||
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
|
||||
of them, Node's `zlib` zstd decoder before 24.22 and 26.10 stops at the first:
|
||||
a real 56-line transcript came back as 1 line. The reader walks frame headers
|
||||
itself, which stays correct on a Node that decodes every frame
|
||||
(nodejs/node#65865). On a Node older than 22.15 (no zstd at all) the mode
|
||||
falls back to the pane, as before.
|
||||
- **Not every `user/message` is the user.** Each turn also records a
|
||||
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
|
||||
prompt.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Discord bridge
|
||||
|
||||
Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network.
|
||||
|
||||
> [!WARNING]
|
||||
> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`; Codex's permission and question dialogs are also read off its screen.
|
||||
- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
|
||||
- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
|
||||
- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
|
||||
- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
|
||||
- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`.
|
||||
- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)).
|
||||
|
||||
## Setup
|
||||
|
||||
Do these once, in order. Never paste the bot token into a chat or a session.
|
||||
|
||||
### 1. Create a private server
|
||||
|
||||
1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**.
|
||||
2. Invite nobody and create no invite links.
|
||||
3. Create one text channel, for example `#codeman-cli`.
|
||||
|
||||
### 2. Copy the IDs
|
||||
|
||||
1. Open **User Settings**, **Advanced** and turn on **Developer Mode**.
|
||||
2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID.
|
||||
|
||||
### 3. Create the application
|
||||
|
||||
1. Go to <https://discord.com/developers/applications> and choose **New Application**.
|
||||
2. Name it `Codeman`.
|
||||
|
||||
### 4. Configure the bot
|
||||
|
||||
On the **Bot** tab:
|
||||
|
||||
1. Choose **Reset Token** and copy it. It is shown once.
|
||||
2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here.
|
||||
3. Leave **Requires OAuth2 Code Grant** off.
|
||||
4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off.
|
||||
|
||||
### 5. Invite the bot with minimal permissions
|
||||
|
||||
On **OAuth2**, **URL Generator**:
|
||||
|
||||
1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else.
|
||||
2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview):
|
||||
|
||||
| Permission | Why |
|
||||
| ------------------------ | ------------------------------------ |
|
||||
| View Channels | See the parent channel |
|
||||
| Send Messages | Post in the parent channel |
|
||||
| Send Messages in Threads | Post in each tab's thread |
|
||||
| Create Public Threads | Make a thread per tab |
|
||||
| Read Message History | Read your replies |
|
||||
| Add Reactions | Acknowledge a reply that was typed |
|
||||
| Attach Files | Send a file from a CLI to the thread |
|
||||
| Embed Links | Show previews for links in posts |
|
||||
|
||||
3. Open the generated URL, choose your private server and authorise.
|
||||
4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot.
|
||||
|
||||
The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`.
|
||||
|
||||
### 6. Optional: allow deleting threads
|
||||
|
||||
Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads.
|
||||
|
||||
### 7. Never grant
|
||||
|
||||
Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission.
|
||||
|
||||
### 8. Configure Codeman
|
||||
|
||||
1. Open **Settings**, **Notifications**, then the **Discord** group.
|
||||
2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist.
|
||||
3. Choose the options below and press **Send test**. A test message should appear in the channel.
|
||||
|
||||
The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE.
|
||||
|
||||
### 9. Phone alerts
|
||||
|
||||
In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values | Default | Effect |
|
||||
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
|
||||
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
|
||||
| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman |
|
||||
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
|
||||
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
|
||||
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
|
||||
|
||||
## Telling the CLIs
|
||||
|
||||
A CLI only posts deliberately if it knows it can. While the bridge is on:
|
||||
|
||||
- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway.
|
||||
- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those.
|
||||
|
||||
## Files
|
||||
|
||||
- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them.
|
||||
- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files.
|
||||
- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it.
|
||||
|
||||
## Security model
|
||||
|
||||
- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped.
|
||||
- A reply is reduced to one line, stripped of control characters and capped in length before it is typed.
|
||||
- Replies are rate limited per session.
|
||||
- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings.
|
||||
|
||||
## Rotating and revoking
|
||||
|
||||
- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**.
|
||||
- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts.
|
||||
- If the token is ever pasted somewhere it should not be, reset it straight away.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Test fails with 401 | The token is wrong or was reset. Paste the current one |
|
||||
| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist |
|
||||
| Thread is not deleted | The bot lacks Manage Threads on the parent channel |
|
||||
| Nothing arrives on the phone | Check the Discord app's notification setting for the server |
|
||||
+1
-1
@@ -59,4 +59,4 @@ A security notice is a sticky warning card about something outside Codeman that
|
||||
- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one.
|
||||
- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one).
|
||||
|
||||
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload.
|
||||
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file.
|
||||
@@ -348,6 +348,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
|
||||
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
|
||||
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
|
||||
| `POST /api/sessions/:id/fix-plan/read` | Reads `@fix_plan.md` on the **remote** host (one probe of the file and the workspace, the same confinement, a 1 MB cap); missing or resolving outside the workspace → `NOT_FOUND`, not a regular file, over the cap or an ssh failure → `OPERATION_FAILED`; never a local fallback |
|
||||
| `POST /api/sessions/:id/fix-plan/write`, `POST /api/sessions/:id/ralph-prompt/write` | `400` (`INVALID_INPUT`) before any disk is touched: remote writes are out of scope, and a local write would land in a same-named local folder |
|
||||
|
||||
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
|
||||
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
|
||||
@@ -429,6 +431,14 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
|
||||
`fs`, and a local session never opens an ssh connection — including for attachment
|
||||
records, which are keyed to the session that registered them.
|
||||
|
||||
**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load
|
||||
(and the `fs.watch` behind it) never runs for a remote session, because the watcher reads
|
||||
the LOCAL disk, where that path is missing or a different tree: `setupSessionListeners()`
|
||||
(`server.ts`) calls `setRemoteWorkingDir()` instead, which only records the path. The fix
|
||||
plan stays authoritative exactly as for a local session, so the tracker's output-based
|
||||
completion guessers stay off there too; the file reaches the tracker only through
|
||||
`fix-plan/read`, over ssh. The image watcher skips remote sessions the same way.
|
||||
|
||||
## Wake-on-LAN from user input
|
||||
|
||||
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
|
||||
|
||||
@@ -105,7 +105,9 @@ The warning points at three ways to secure the instance:
|
||||
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`).
|
||||
3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`
|
||||
— explicitly accept the risk (downgrades the warning to a one‑line note). This
|
||||
flag is **only** an acknowledgement; it does not change reachability.
|
||||
flag is **only** an acknowledgement; it does not change reachability. It covers
|
||||
the bind and nothing else: it does **not** let the managed Cloudflare tunnel
|
||||
start without a password, which has its own opt‑in (§2, the tunnel caveat).
|
||||
|
||||
`CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback
|
||||
address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the
|
||||
@@ -209,8 +211,15 @@ requests as local:
|
||||
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
||||
up — it gates everything except the (secret‑gated) hook exemption and is the
|
||||
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
|
||||
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
|
||||
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
|
||||
without a login (`CODEMAN_PASSWORD` or multi‑user mode) unless
|
||||
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel`
|
||||
acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true`
|
||||
(what the browser sends after its confirm). The network acknowledgement
|
||||
(`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes
|
||||
it for every LAN install without a password, so it once turned a click on the
|
||||
welcome screen's tunnel link into a public, login‑free URL with no confirm. With
|
||||
no login the browser asks before every enable, whatever the server would accept,
|
||||
reading `loginConfigured` from `GET /api/tunnel/status`. Prefer `tailscale serve` (below), which authenticates
|
||||
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||
|
||||
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
||||
@@ -573,6 +582,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
---
|
||||
@@ -585,7 +595,8 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
|
||||
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
|
||||
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning). Does not cover the tunnel |
|
||||
| `--allow-unauthenticated-tunnel` / `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Let the managed Cloudflare tunnel start without a login (the browser still asks first). Never written by the installer |
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
|
||||
@@ -192,7 +192,7 @@ start/stop wrapper to manage; tailscaled owns the lifecycle).
|
||||
| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. |
|
||||
| `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. |
|
||||
| Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. |
|
||||
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
|
||||
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedTunnelAcknowledged` and the login check in the `PUT /api/settings` tunnel guard). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. |
|
||||
| Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. |
|
||||
| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). |
|
||||
|
||||
|
||||
@@ -30,8 +30,13 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
3. **Documented environment variables** that configure deployment:
|
||||
`CODEMAN_PASSWORD`, `CODEMAN_USERNAME`, `CODEMAN_HOST`, `CODEMAN_PORT`,
|
||||
`CODEMAN_INSTANCE`, `CODEMAN_ALLOWED_HOSTS`, `CODEMAN_DATA_DIR`,
|
||||
`CODEMAN_TMUX_SOCKET`, and the `--host` / `--port` / `--https` CLI flags.
|
||||
Removing or changing the meaning of one of these is breaking.
|
||||
`CODEMAN_TMUX_SOCKET`, `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`,
|
||||
`CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`, and the `--host` / `--port` /
|
||||
`--https` / `--allow-unauthenticated-network` /
|
||||
`--allow-unauthenticated-tunnel` CLI flags. Removing or changing the meaning
|
||||
of one of these is breaking. The two acknowledgements are listed from 1.46.0,
|
||||
the release that split them: the network one covers the non-loopback bind
|
||||
only, and an unauthenticated tunnel needs the tunnel one.
|
||||
4. **The HTTP API and SSE event channel**, served under **`/api/v1`** with the
|
||||
uniform `{success:true,data}` / `{success:false,error,errorCode}` envelope and
|
||||
conventional HTTP status codes. Endpoint paths, the response envelope, error
|
||||
@@ -62,6 +67,25 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
||||
anything labeled experimental in the UI or docs. These may change or be
|
||||
removed at any time.
|
||||
|
||||
### Experimental agent coordination
|
||||
|
||||
`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and
|
||||
the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage`
|
||||
and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since
|
||||
every `/api` route is also served under `/api/v1`), are labeled experimental, so
|
||||
item 3 above applies to them: their commands, flags, endpoint paths, payloads and
|
||||
behavior may change or be removed in a MINOR or PATCH release. They are also
|
||||
opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced
|
||||
`agentCoordinationEnabled` setting (default OFF) until it is turned on, so
|
||||
automation cannot come to depend on them without choosing to. Pin a compatible
|
||||
Codeman version when automating them. Existing authentication, ownership and
|
||||
shared response-envelope rules still apply. The session behavior underneath them
|
||||
(input revision tracking in the session's write paths, the SubmitVerifier
|
||||
re-press going through the shared write boundary) runs for every session and is
|
||||
covered like any other stable behavior. Other agent commands and API routes keep
|
||||
their existing guarantees. Promotion to stable requires an explicit documented
|
||||
decision.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
When we need to change a covered surface:
|
||||
|
||||
@@ -123,8 +123,11 @@ message a worker directly instead of typing into its terminal.
|
||||
|
||||
## What it will and will not do
|
||||
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and
|
||||
never guesses an API URL, so a global install costs an unrelated agent session nothing.
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses every live
|
||||
API operation and never guesses an API URL, so a global install cannot drive a server it
|
||||
is not part of. The one exception is reading saved Codex conversations with
|
||||
`codeman history read` (see [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#reading-saved-codex-conversations)),
|
||||
a local, read-only command that needs no server or session.
|
||||
- **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created
|
||||
in that conversation, by exact id**, behind a guard that refuses to delete the agent's own
|
||||
session.
|
||||
@@ -153,6 +156,8 @@ The skill checks for both and says so instead of acting.
|
||||
| [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. |
|
||||
| [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. |
|
||||
| [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. |
|
||||
| [`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md) | Reading saved Codex conversations offline with `codeman history read`: selectors, filters, forks, budgets. Loaded on demand. |
|
||||
| [`reference/inspection-and-staging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/inspection-and-staging.md) | Experimental and opt-in (App Settings, Experimental agent coordination): bounded pane inspection and guarded submission with `codeman agent inspect`, `stage` and `submit`. Loaded on demand. |
|
||||
|
||||
Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a
|
||||
quick run stays quick.
|
||||
|
||||
@@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o
|
||||
| `clis.json` | CLI enable/disable and custom CLI entries. |
|
||||
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
|
||||
| `webhook.json` | Webhook notification target. |
|
||||
| `discord.json` | Discord bridge settings. **Holds a bot token.** |
|
||||
| `intents.json` | Read My Mind per-case profiles. |
|
||||
| `linked-cases.json` | Registered cases. |
|
||||
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
|
||||
@@ -188,15 +189,15 @@ that are the same are listed on one line. A file that is not JSON shows only tha
|
||||
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
|
||||
self-rewritten fields listed under "When a backup is made" are not counted as changes.
|
||||
|
||||
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the
|
||||
URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
|
||||
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the
|
||||
bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
|
||||
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
|
||||
query and fragment.
|
||||
|
||||
## Security
|
||||
|
||||
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
|
||||
`push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default
|
||||
`push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
|
||||
location exposes nothing the data directory does not already.
|
||||
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
|
||||
network share copies those credentials there, with whatever permissions that location has.
|
||||
|
||||
@@ -22,11 +22,11 @@ Codeman-side configuration:
|
||||
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
|
||||
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<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. |
|
||||
| How | Result |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||
registration, and for a linked case that is all it removes.
|
||||
|
||||
@@ -27,14 +27,20 @@ hosts — these are machine-level infra, not a per-user setting.
|
||||
shows up without another manual click of **Discover**. One endpoint being unreachable on a
|
||||
given cycle (powered off, wrong network) never blocks the others from refreshing.
|
||||
|
||||
**Context length is picked up automatically where it can be, safely.** Against a
|
||||
llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
|
||||
context window and applies it to the launched session (Claude Code today — see below), so
|
||||
the harness stops assuming a large default window for a model name it doesn't recognise and
|
||||
overflowing a much smaller real one. It's deliberately never probed for a model that isn't
|
||||
already loaded, since asking a llama-swap server about an unloaded model can trigger an
|
||||
actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
|
||||
context length an earlier cycle already learned for it instead.
|
||||
**Context length is picked up automatically where it can be, safely.** Every model's own
|
||||
listing entry is read for a stated context figure (`context_length`, then `context_window`,
|
||||
which current llama.cpp, llama-swap and gateway-style servers state to varying degrees);
|
||||
against a llama-swap server the stronger figures from its launch command and from llama.cpp's
|
||||
`/props` probe win where they exist, and those probes only ever run for a model the server
|
||||
already reports loaded, since asking about an unloaded model can trigger an actual, slow model
|
||||
swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned:
|
||||
the stored figure always beats the endpoint's **Default context length** field
|
||||
(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was
|
||||
ever learned (that number is the model's whole window, never a per-slot figure). A model
|
||||
neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses
|
||||
that can take it: Claude Code today (see below), OpenCode as the model's context limit, and
|
||||
Codex as a `model_context_window` entry in its generated config file, which also sets Codex's
|
||||
own auto-compact point via `model_auto_compact_token_limit` at 75% of the window.
|
||||
|
||||
## Running a session against one
|
||||
|
||||
@@ -48,15 +54,20 @@ pointed at.
|
||||
|
||||
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
small dialog asks which one to use for this launch before starting the session; a search box
|
||||
at the top narrows a long list with a case-insensitive substring match, so a server hosting
|
||||
hundreds of models stays usable. The
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
**Currently loaded**. When nothing is reported loaded, the model one of your own running
|
||||
sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it
|
||||
beats **Last used**, never **Currently loaded**), and failing that the model you last launched
|
||||
on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser
|
||||
value, so another device starts from its own history). With none of those, a line under the
|
||||
search box says nothing is marked, so the plain list is not mistaken for an answer. The default
|
||||
model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the
|
||||
promoted row is simply the one under your thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
@@ -186,7 +197,7 @@ read is treated as one slot, never guessed.
|
||||
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
||||
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
||||
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
||||
| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
|
||||
| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. |
|
||||
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
|
||||
|
||||
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Discord Bridge
|
||||
|
||||
Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord
|
||||
server when a session needs you, gives every tab its own thread, and types your reply in that
|
||||
thread into that tab. It only makes outbound connections, so nothing is exposed to the internet
|
||||
and it works when you are away from your network.
|
||||
|
||||
**Opt-in, off by default. One server, one channel, threads per tab.**
|
||||
|
||||
> **Treat the Discord account like a login to the machine.** A reply from an allowed account is
|
||||
> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A
|
||||
question or permission prompt shows what is on the tab's screen right now, so you can see what is
|
||||
being asked; a finished turn or an error carries the CLI's whole last message.
|
||||
- **One thread per tab** in a parent channel, so a reply always reaches exactly one session.
|
||||
- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter).
|
||||
- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can
|
||||
send a file back.
|
||||
- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent`
|
||||
posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents).
|
||||
|
||||
## Set it up
|
||||
|
||||
The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token
|
||||
handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md).
|
||||
In short:
|
||||
|
||||
1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs.
|
||||
2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and
|
||||
invite it with the `bot` scope and only the permissions the guide lists (no Administrator).
|
||||
3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**,
|
||||
then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again.
|
||||
|
||||
The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner
|
||||
only), never in the shared settings, and never shown again once saved.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Choices | Default |
|
||||
| ---------------------------------------- | ------------------------------------------- | --------------- |
|
||||
| Create threads | On the first alert, or for every new tab | First alert |
|
||||
| When a tab is deleted | Keep, archive or delete the thread | Archive |
|
||||
| Stale threads | Keep, archive or delete threads no tab uses | Keep |
|
||||
| @ ping | Never, critical events only, every post | Critical only |
|
||||
| Which events | Needs attention, or everything | Needs attention |
|
||||
| Tell Claude how to reach you | On or off | On |
|
||||
|
||||
A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions
|
||||
kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines
|
||||
of the screen instead.
|
||||
|
||||
Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which
|
||||
events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, OpenCode, Gemini,
|
||||
Antigravity, Pi, Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`;
|
||||
Codex's dialogs are read off its screen.
|
||||
|
||||
The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a
|
||||
deleted tab's thread leaves the bridge's map.
|
||||
|
||||
Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
|
||||
permission prompt, a question, an error, or a post an agent marked urgent.
|
||||
|
||||
## Telling the agents
|
||||
|
||||
An agent only posts deliberately if it knows it can:
|
||||
|
||||
- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
|
||||
- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to.
|
||||
|
||||
Tabs that were already open when you turned the bridge on are not told. The alerts still reach
|
||||
Discord for them; only the agent-initiated `codeman notify` habit is missing.
|
||||
|
||||
## Files
|
||||
|
||||
- **You to the agent:** attach a file to a reply. It is saved as
|
||||
`<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with
|
||||
`[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed.
|
||||
- **Agent to you:** `codeman notify "message" --file <path>` uploads a file of at most 8 MB that lies
|
||||
inside the tab's folder. Credential files such as `.env` are refused.
|
||||
- Remote (ssh) tabs have no local folder, so they neither send nor receive files.
|
||||
- A file you send is untrusted content to the agent that opens it.
|
||||
|
||||
## Safety
|
||||
|
||||
- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on.
|
||||
Everything else is dropped.
|
||||
- A reply is cut to one printable line (1000 characters) and limited to one per second per tab.
|
||||
- Posts can ping only the allowlisted users, so agent output cannot ping the whole server.
|
||||
- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| Send test says 401 | The token is wrong or was reset. Paste the current one. |
|
||||
| Send test says the bot cannot see it | Check the bot's role and the channel ID. |
|
||||
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. |
|
||||
| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. |
|
||||
| A thread is not deleted | The bot lacks Manage Threads on the channel. |
|
||||
|
||||
See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear
|
||||
from an agent, and [Security](Security) for how secrets are handled.
|
||||
@@ -41,9 +41,13 @@ act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"oth
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an existing directory (cwd by default)
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor
|
||||
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
@@ -53,6 +57,21 @@ codeman agent ack <msg-id> # remove what you hand
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
- **`spawn [case]`** uses the current directory when no case is supplied. Use
|
||||
`codeman agent spawn --working-dir /path/to/project --mode codex` for another existing
|
||||
local directory. A case and `--working-dir` cannot be combined. An empty case name
|
||||
(`spawn "$CASE"` with `CASE` unset) is never read as "no case": it goes to quick-start,
|
||||
which refuses it (exit 1). An empty `--working-dir` is refused before any request
|
||||
(exit 4), so it never falls back to cwd.
|
||||
A cwd that is your home directory is refused the same way, because Codeman's hooks would
|
||||
land in `~/.claude`; pass `--working-dir ~` to start a worker there on purpose.
|
||||
Explicit case names retain quick-start behavior (creating a scratch case if missing).
|
||||
A directory launch is `POST /api/v1/sessions` followed by
|
||||
`POST /api/v1/sessions/:id/interactive` (or `/shell`); it preserves the project and
|
||||
parent lineage, and never labels it as agent scratch. Choose a separate worktree for
|
||||
each worker that will edit code. If startup fails or its response is lost, the CLI
|
||||
reports the exact created session ID and exits `1`; inspect that session before
|
||||
deciding what to do next.
|
||||
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
|
||||
ambiguous prefix.
|
||||
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
|
||||
@@ -67,8 +86,49 @@ codeman agent rm "$SID" # any session except t
|
||||
`ls` shows each session's mail in an INBOX column. Mail lives in memory unless
|
||||
**Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)),
|
||||
and goes when its session is deleted.
|
||||
- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN`
|
||||
naming the setting until **Experimental agent coordination** is on (see
|
||||
[Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by
|
||||
default), and they may change in any release. `inspect` returns bounded evidence with an
|
||||
opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a
|
||||
local Codex composer, with no Enter, and returns a receipt that expires after two minutes
|
||||
once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands
|
||||
and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter
|
||||
would accept), so send commands with `send`. `submit` sends one Enter only if the
|
||||
composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details:
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input).
|
||||
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
|
||||
worker exited, `4` refused. `--json` prints the response's `data`.
|
||||
worker exited, `4` refused. `--json` prints the response's `data`, except a directory
|
||||
`spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a
|
||||
readiness wait ran, `readinessError` when that call failed, and `startupError` when the
|
||||
start failed.
|
||||
|
||||
## Reading saved Codex conversations
|
||||
|
||||
`codeman history read` is the one command here that needs no server and no Codeman
|
||||
session: it reads Codex's own saved rollouts (`$CODEX_HOME/sessions`, else
|
||||
`~/.codex/sessions`) from disk and writes nothing.
|
||||
|
||||
```bash
|
||||
codeman history read --session-id 019fc604 # one conversation, by id or 8+ character prefix
|
||||
codeman history read --root-session-id <uuid> --topic auth # it and every sub-agent or fork under it
|
||||
codeman history read --file /path/to/rollout.jsonl --format text
|
||||
```
|
||||
|
||||
- **Filters** keep whole user/assistant turns: `--topic` is a literal, case-insensitive
|
||||
match, and `--after`/`--before` are inclusive UTC dates.
|
||||
- **Forks and sub-agents**: a child's copy of its parent's history is dropped, so every
|
||||
record belongs to the thread that wrote it.
|
||||
- **`complete: false`** in the JSON means the evidence is partial: a store file that could
|
||||
not be attributed, a compressed `.jsonl.zst` rollout (not read), a record a running
|
||||
Codex is still writing, an ancestor not found.
|
||||
- **Budgets** fail closed and are raisable: `--metadata-bytes` (attributing the store,
|
||||
default 1 GiB), `--max-entries` (65536), `--scan-bytes` (the selected transcripts,
|
||||
16 MiB) and `--max-bytes` (output, 8192). `--json` and `--format text` together are an
|
||||
error.
|
||||
|
||||
The full reference ships with the skill as
|
||||
[`reference/history.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/history.md).
|
||||
|
||||
## The MCP server
|
||||
|
||||
|
||||
+2
-1
@@ -42,7 +42,7 @@ codeman web # then open http://localhost:3000
|
||||
**Driving it from code**
|
||||
|
||||
- [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions.
|
||||
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives.
|
||||
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives, reading saved Codex conversations offline.
|
||||
- [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events.
|
||||
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman.
|
||||
|
||||
@@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000
|
||||
| ------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? |
|
||||
| [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? |
|
||||
| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? |
|
||||
| [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? |
|
||||
| [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? |
|
||||
| [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? |
|
||||
|
||||
@@ -153,6 +153,12 @@ already writing in the editor.
|
||||
|
||||
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
|
||||
visual viewport rather than guessed.
|
||||
- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint
|
||||
its screen at the new height and drop transcript lines from the session's history, so for a
|
||||
local Claude session the terminal keeps its size while the keyboard is up and the view
|
||||
scrolls instead, with the prompt and the line under it just above the keyboard. Codex,
|
||||
Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under
|
||||
Keyboard** in App Settings turns it off on this device.
|
||||
- **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice
|
||||
on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row
|
||||
keeps focus so you can place the caret.
|
||||
|
||||
@@ -13,6 +13,7 @@ opening the session.
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in |
|
||||
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
@@ -66,6 +67,11 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Discord bridge: answer from your phone
|
||||
|
||||
A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a
|
||||
thread per tab, your reply typed into that tab, files in both directions.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
@@ -115,8 +115,10 @@ There is also a toggle in **App Settings → System → Remote access**.
|
||||
|
||||
**The tunnel refuses to start without a password.** That is on purpose: a public URL with no
|
||||
authentication is a terminal on your machine handed to the internet. Acknowledging the risk
|
||||
explicitly is possible from the UI toggle, and only from there; the API will not do it for
|
||||
you.
|
||||
explicitly is possible from the UI toggle, which asks you every time, or for the whole server
|
||||
with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` (or `codeman web --allow-unauthenticated-tunnel`).
|
||||
`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`, which the installer sets for a LAN install without a
|
||||
password, does not count.
|
||||
|
||||
## LAN plus password
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ In order of how much they matter:
|
||||
|
||||
1. **Do not expose it without `CODEMAN_PASSWORD`.** Binding a non-loopback host without one
|
||||
starts, but warns loudly. A tunnel refuses outright unless you acknowledge the exposure
|
||||
in the UI.
|
||||
in the UI, which asks every time, or set `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL`. The
|
||||
installer's `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` covers the LAN bind only.
|
||||
2. **Prefer Tailscale over a public tunnel.** Keeping the loopback bind and putting a
|
||||
private network in front of it removes the public attack surface entirely, and gives you
|
||||
real HTTPS. See [Remote Access](Remote-Access).
|
||||
|
||||
@@ -21,7 +21,7 @@ desktop.
|
||||
|
||||
| Category | Examples |
|
||||
| ----------------------- | ------------------------------------------------------------------------------- |
|
||||
| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. |
|
||||
| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. |
|
||||
| **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. |
|
||||
| **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. |
|
||||
|
||||
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| Setting | Default | Notes |
|
||||
| ----------------------------- | -------------------- | --------------------------------------------------------------------- |
|
||||
| Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). |
|
||||
| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). |
|
||||
| CJK Input | Off | IME composition through a dedicated text field. |
|
||||
| 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. |
|
||||
@@ -61,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
@@ -84,8 +85,32 @@ by folder** (per device, on by default) shows changed files under collapsed fold
|
||||
window; off lists every file by its full path. **Git status: max repositories** (per device,
|
||||
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
|
||||
long one git command may run before that repository is reported as unreadable; raise it for
|
||||
repositories on a slow network share.
|
||||
long one git command may run before that repository is reported as unreadable, and the same value
|
||||
bounds the Git window's Fetch button; raise it for repositories on a slow network share.
|
||||
|
||||
**Git fetch on an interval** (synced, off by default) runs `git fetch` on a timer for the repositories the
|
||||
Git window lists, so the `behind` number is current without a click, and **Git fetch: how often** (synced,
|
||||
30 to 3600 seconds, default 300) is the gap between runs for a session that has no override of its own, timed from
|
||||
the start of a run, so a slow repository does not push the next one out. These are the only Git settings
|
||||
that are synced rather than per device, because the server's scheduler is what acts on them: kept per
|
||||
device, the scheduler would read nobody's number. **Session Options → Git** overrides both the switch and
|
||||
the interval for one session, and that override wins even while the app switch is off; a session that follows
|
||||
the app shows the app's values and its rows go inert until you turn **Follow the app setting** off. Nothing is
|
||||
ever fetched for a Docker or remote (SSH) session, and a repository a container can write to is refused
|
||||
before any git runs. Sessions open on the same folder share one run, and a folder is fetched when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins. Both default to off because `git fetch`
|
||||
is the one git command the Git window runs that writes (cloning and worktree management write too, but only
|
||||
when you asked for them at the moment you asked), and the one that can run a repository's own hooks
|
||||
(`reference-transaction`, `pre-auto-gc` through the maintenance a fetch triggers, and config commands such as
|
||||
`credential.helper`), and the one that can run later by itself, on a timer. The ssh it runs is BatchMode so
|
||||
nothing waits for a login, and that is a default, never an override: your own `GIT_SSH_COMMAND`, or the
|
||||
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
|
||||
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
|
||||
|
||||
**Edit history** (per device, off by default, never on phones) adds a header button for the
|
||||
active session's edit history: every edit the agent and its subagents made, with its diff, and
|
||||
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
|
||||
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
|
||||
[Working With Files](Working-With-Files#edit-history).
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||
@@ -106,6 +131,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha
|
||||
memory. Messages expire 12 hours after they were posted either way. See
|
||||
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
|
||||
|
||||
**Experimental agent coordination** (under **Cross-session features**, synced, off by
|
||||
default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded,
|
||||
paged reads of another session's output, and typing a prompt into a local Codex composer
|
||||
that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP
|
||||
routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are
|
||||
experimental and may change in any release. See
|
||||
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
|
||||
|
||||
### Appearance
|
||||
|
||||
| Setting | Notes |
|
||||
@@ -121,6 +154,7 @@ memory. Messages expire 12 hours after they were posted either way. See
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| New Session Button on Tabs | Adds a **+** beside each tab's gear: pick a CLI the tab's case can start and one session starts in that case (or worktree) and named group, leaving the Run button alone. The row menu offers it as *New session here…*. Per device, off by default. See [The Dashboard](The-Dashboard#session-tabs). |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
|
||||
@@ -185,8 +219,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser
|
||||
notification display time**, default 8; both per device, and your OS may close a
|
||||
desktop notification sooner), the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the
|
||||
Discord group for the two-way bot (token, IDs, thread and ping options; see
|
||||
[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -244,7 +279,8 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_GESTURE` | Makes gesture control available to be enabled. |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. |
|
||||
| `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. |
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges binding a non-loopback address with no password. Does not cover the tunnel. |
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL` | Lets the Cloudflare tunnel start with no password (`--allow-unauthenticated-tunnel` on `codeman web`). The browser still asks first. |
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
|
||||
@@ -77,6 +77,14 @@ An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**New Session Button on Tabs** (same place, per device, off by default) adds a **+** beside
|
||||
the tab's gear. It lists the CLIs that tab's case can start, each with its logo and name (a
|
||||
Docker case lists what its container has), and a pick starts one session in that tab's case,
|
||||
or the worktree it runs in, and in its named group. The Run button keeps its own mode and case.
|
||||
Wherever a tab has a row menu (the vertical rail, the sidebar, `Shift+F10` on a grouped row),
|
||||
the same list is its **New session here…** entry. From a tab outside any named group, the new
|
||||
session lands in the case's default group if the case has one, as a Run from the toolbar does.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
@@ -87,7 +87,10 @@ detected at once would fork ten multi-minute converter processes.
|
||||
|
||||
File paths in a session are links. That works in two places:
|
||||
|
||||
- **In the terminal**, on any absolute path an agent prints.
|
||||
- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`,
|
||||
`../notes.md`) resolves against the directory the session's pane is in now, so after a `cd`
|
||||
in a shell it opens the file there; until the pane reports a directory, and always for remote
|
||||
SSH and Docker cases, it resolves against the session's working directory.
|
||||
- **In the response viewer**, where paths are usually written as prose or in backticks. They
|
||||
render as underlined monospace links. A Markdown link whose target is a path
|
||||
(`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way.
|
||||
@@ -168,6 +171,11 @@ choice is remembered per device. The picker inserts the path into your prompt
|
||||
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
|
||||
key clears the unsent prompt, and never sends the agent's `/clear` command.
|
||||
|
||||
In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you
|
||||
are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting
|
||||
with a dot or `~`). The cases folder is refused, since every folder there is already a
|
||||
case: use **Add Case → New Case** instead.
|
||||
|
||||
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
|
||||
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
|
||||
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
|
||||
@@ -245,14 +253,87 @@ Click it for a draggable window, in the style of the File Viewer:
|
||||
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
|
||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||
in your home folder) is ignored.
|
||||
- **Fetch** in the header runs `git fetch` for the repositories the window lists, on your click; the
|
||||
window then re-reads, so "behind" is as of that fetch. A repository that could not be fetched keeps
|
||||
its row and shows why. **Git fetch on an interval** (Settings → Bottom bar, off by default) runs the
|
||||
same fetch on a timer for every session that is on, and **Session Options → Git** overrides both the
|
||||
switch and the interval for one session: a session that turned itself on runs even while the app-wide
|
||||
switch is off, sessions open on the same folder share one run, and nothing is ever fetched for Docker or
|
||||
remote (SSH) sessions.
|
||||
|
||||
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
|
||||
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||
workspace is skipped even from a local session (a container can write there, and git would run
|
||||
that repository's own configuration on the host). The
|
||||
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||
It is read-only apart from that fetch: Codeman never commits, checks out or changes the
|
||||
repository, and `git fetch` writes remote-tracking refs only; it is the only git command the Git window
|
||||
runs that writes, the only one that can run a repository's own hooks (`reference-transaction`,
|
||||
`pre-auto-gc` through the maintenance a fetch triggers, and config commands such as `credential.helper`),
|
||||
and the only one that can run later by itself, on a timer: cloning and worktree management write too, but
|
||||
only when you asked for them at the moment you asked, which is why nothing runs on a timer until you opt in. A fetch holds git's ref lock, so an agent's own `git pull` in the same repository
|
||||
can hit the same `cannot lock ref`, and a folder is fetched by one run at a time. It is not shown for Docker
|
||||
or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||
workspace is skipped even from a local session, before any git runs (a container can write there,
|
||||
and git would run that repository's own configuration on the host). Fetch asks nothing of you: it
|
||||
uses the sign-in your own git already has, and it gives up quickly rather than waiting on an
|
||||
invisible login prompt. That BatchMode ssh is a default, never an override: your own
|
||||
`GIT_SSH_COMMAND`, or the repository's `core.sshCommand`, is used when either is set, and ⚠️ the default's
|
||||
`StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking:
|
||||
BatchMode stops a prompt, not a write. The
|
||||
data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-diff` and, on that
|
||||
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Edit history
|
||||
|
||||
The Git panel shows what is different now. The edit history shows what the agent did, one edit
|
||||
at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
|
||||
Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
|
||||
header next to Attachments. It is not shown on phones.
|
||||
|
||||
Click it for a draggable window listing the active session's edits, newest first:
|
||||
|
||||
- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
|
||||
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
|
||||
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
|
||||
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
|
||||
its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
|
||||
their file, with the totals on the file's row.
|
||||
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
|
||||
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or
|
||||
its output counts when the file's modification time falls within that command's run. Images the
|
||||
agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one
|
||||
to open it in the preview.
|
||||
- **Load older** reads further back in the conversation, in the main agent's transcript and the
|
||||
subagents' alike (up to three transcripts per click; with more, they take turns).
|
||||
- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window
|
||||
first catches up on the ones it had not read yet. **Load older** then goes back within the new
|
||||
conversation.
|
||||
|
||||
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
|
||||
|
||||
How it reads:
|
||||
|
||||
- **Only while you look.** The history is read from the session's Claude transcript when you open
|
||||
the window, and again after each tool call that can change files and at the end of each turn,
|
||||
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
|
||||
the background for sessions nobody looks at.
|
||||
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
|
||||
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
|
||||
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
|
||||
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
|
||||
is only known after the next prompt; until then the window says "No transcript found for this
|
||||
session yet".
|
||||
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
|
||||
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
|
||||
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
|
||||
are dropped), never part of search, blocked from every file route, and deleted when you delete
|
||||
the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and
|
||||
delete it.
|
||||
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
|
||||
Viewer's route. An image outside it opens like any clicked path outside the workspace (no
|
||||
notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
|
||||
|
||||
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
|
||||
`POST /api/sessions/:id/edits/older` (see the
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
- [Unattended Runs](Keeping-Agents-Running)
|
||||
- [Notifications & Approvals](Notifications-And-Approvals)
|
||||
- [Discord Bridge](Discord-Bridge)
|
||||
- [Cron Jobs](Cron-Jobs)
|
||||
- [Autonomous Loops](Autonomous-Loops)
|
||||
- [Watching Agents Work](Watching-Agents-Work)
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -13389,7 +13389,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- de1aab9: ### Thanks
|
||||
- @JDProfresh for five PRs: a phone keyboard that no longer costs Claude its transcript (#711, measured in #672), a + on each session tab that starts a session beside it (#712, shaped in Discussion #700), a tunnel that needs its own opt-in instead of the LAN one (#718, from their report #706), a settings.json that two writers can no longer tear (#719), and a DeepSeek test that passes on current Node (#710).
|
||||
- @aakhter for six PRs: `codeman agent spawn` in a project directory (#708), worker ids kept when the readiness check fails (#709), the offline Codex history reader (#707), experimental bounded inspection and guarded submission for agents (#694, from Discussion #695), links that follow URLs and paths across wrapped rows and table cells (#686), and file paths with spaces linked as one path (#687).
|
||||
- @opticon454 for the Fetch button and fetch interval in the Git status window (#714).
|
||||
- @johoja12 for Cmd+K arrows that switch the CLI both ways after you type a case name (#717).
|
||||
|
||||
**Fetch from the Git status window (#714).** The Git status window has a Fetch button next to Refresh. It runs `git fetch` for the repositories it lists and shows each one's result. An optional interval runs it for you: off by default, app-wide in App Settings, Bottom bar, with a per-session override in Session Options, Git. A fetch only moves remote-tracking refs (HEAD, the index and the working tree stay as they are), it never runs for a Docker or remote session, and it is bounded in time, output and concurrency.
|
||||
|
||||
**Start a session beside any tab (#712, opt-in).** Turn on **New Session Button on Tabs** (App Settings, Tabs) and each session tab gets a +: pick any CLI its case can run (a Docker case offers what its container has) and the new session starts in that case or worktree and that tab's group, without touching the toolbar's case or Run mode. The row menu offers the same as "New session here…" for the keyboard.
|
||||
|
||||
**The phone keyboard no longer costs Claude its transcript (#711).** Every keyboard open and close used to resize the PTY, and Claude Code answers each height change by reprinting only the tail that fits, so lines above it were lost from tmux for good (25 of 100 in five keyboard cycles). A local Claude session now keeps its rows while the keyboard is up and slides the screen so the composer stays above the keyboard. On by default as **Keep Size Under Keyboard** (App Settings, per device); off restores the old resize. Fullscreen Claude (mouse tracking on) still resizes as before.
|
||||
|
||||
**Typing stays smooth in a long prompt (#713).** The local echo overlay (the instant preview of what you type) rebuilt every character of the prompt on each keystroke, so on a phone-class CPU a 4,000-character prompt took most of a second per key. It now rebuilds only the line that changed, with one paint layer per line, so a keystroke costs the same however long the prompt is. Every character still sits on its terminal cell. This ships in `xterm-zerolag-input` too.
|
||||
|
||||
**Links survive wrapping and spaces (#686, #687).** A URL or path a CLI wrapped inside a table cell or an indented paragraph now links whole instead of in fragments, URLs keep balanced parentheses (`…/Team-Space(QA)Archive/…`), a full-width table divider no longer glues onto the row below, and OSC 8 hyperlinks open through Codeman's own opener after a confirmation. A file path with spaces (`/mnt/c/OneDrive - Contoso/report.docx`, `Saved to: ~/My Docs/plan.md`) links as one path in the terminal and the response viewer when the line bounds it, and a shell prompt that prints its working directory no longer turns the whole command line into one link.
|
||||
|
||||
**`codeman agent spawn` in a project directory (#708).** Without a case name, `spawn` starts the worker in the current directory, or in an existing directory given with `--working-dir`, through `POST /api/v1/sessions` and the mode's start route. Named cases still use quick-start. `--json` prints `{ sessionId, workingDir, created: true }` plus `ready`, `readinessError` or `startupError`. An empty `--working-dir`, a bare spawn from your home directory (it would install Codeman's hooks into `~/.claude`; pass `--working-dir ~` to mean it) and an unknown `--mode` are refused with exit 4 before any request.
|
||||
|
||||
**Read saved Codex conversations offline (#707).** `codeman history read` reads Codex's saved rollouts without a Codeman server: by file, by Codex session id, or a whole conversation with its sub-agents and forks, filtered by topic or UTC date with whole turns kept. Reads and output have separate, raisable budgets, and anything it could not read or attribute (a compressed `.jsonl.zst` rollout, a file still being written) is reported through `complete: false` instead of failing the lookup. The codeman skill routes to it.
|
||||
|
||||
**Experimental: bounded inspection and guarded submission for agents (#694, opt-in).** `codeman agent inspect` reads bounded, cursor-paged evidence from another session (a local Codex session's attributed rollout records, otherwise a labelled pane snapshot), and `agent stage` / `agent submit` type one line into a verified local Codex composer and later send exactly one Enter against a hash-confirmed receipt. Off by default behind **Experimental agent coordination** (App Settings, Cross-session features); while off the three routes answer `403 FORBIDDEN` naming the setting. These routes and verbs are experimental under the versioning policy and may change in a minor or patch release. During the few milliseconds of a guarded submit, other input is held and written in order afterwards; a tagged input POST in that window answers `409 CONFLICT` so its sender retries, and an untagged one (the phone keys, the Stop button) is held and answered once written.
|
||||
|
||||
**Cmd+K arrows switch the CLI both ways (#717).** After typing a case name in Cmd+K, plain Left and Right now both change the New session row's CLI whenever the caret is at either end of the query (only Right did before). Inside the query the arrows move the caret as usual.
|
||||
|
||||
**Fixes.**
|
||||
- **Security: a LAN install's acknowledgement no longer opens a public tunnel (#718).** `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` (and `--allow-unauthenticated-network`), which the installer writes for every LAN install without a password, also let the Cloudflare tunnel start, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. It now covers the network bind only. An unauthenticated tunnel has its own opt-in, `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `codeman web --allow-unauthenticated-tunnel` (carried by `codeman web -d` and `codeman service install`), which the installer never writes; if you relied on the network variable to start the tunnel without a password, set one of those. With no password and no multi-user mode, the browser now asks before every tunnel enable, using the new `loginConfigured` field of `GET /api/tunnel/status`. Both acknowledgement variables and their flags are now listed as stable in the versioning policy.
|
||||
- **settings.json can no longer be torn (#719).** A quick-start's `lastUsedCase` write was not awaited and not atomic, so it could overlap the next settings save and leave one write's bytes over the tail of the other, after which every reader saw `{}`. Every write to settings.json now goes through one serialized writer that writes a temp file and renames it into place, and a file that does not parse is refused with `409 CONFLICT` instead of being overwritten with only the writer's own keys. A case rename checks this before it ends any session.
|
||||
- **A worker id is never lost (#709).** When `codeman agent spawn` created a worker but its readiness request failed, it exited without the new session id. It now prints the id (and `--json` carries it) and exits 1, so the caller can inspect or delete the worker. `codeman agent` and `codeman mcp` also name the error code (such as `ECONNREFUSED`) when a request fails with an empty message.
|
||||
- **The DeepSeek transcript test passes on Node 24.22+ and 26.10+ (#710).** It pinned Node's old zstd decoder, which stopped at the first frame until nodejs/node#65865.
|
||||
|
||||
**Fixes applied while landing.** The Git fetch interval field is no longer reset by a session update while you type in it, a session holding only half an override shows the app's value for the other half, reading a repository's `core.sshCommand` no longer doubles the fetch timeout, a `GIT_SSH` in the server's environment is honoured, and the Fetch button's disabled and busy styles no longer reach other icon buttons and stop pulsing under reduced motion (#714). A + launch keeps the toolbar on its own case, and pressing the + again closes its picker (#712). The fetch scheduler no longer logs the text of a malformed settings.json on every 15-second tick (the parser's message could quote an API key; it now says the file is not valid JSON, once), and a fetch is skipped while the Docker case list cannot be read, so it can never run inside a Docker workspace (#714). A case rename answers `409 CONFLICT` for a settings.json it cannot write before it ends any session (#719). The unauthenticated-tunnel warning no longer fires in multi-user mode, where the tunnel has a login in front of it (#718). Untagged input during a guarded submit is held instead of refused, and the skill's staging guide says so (#694). A shell prompt line (a spaced working directory followed by `$` included) and a labelled `../` spaced path link as they should, and a bold label or inline code before a path in the response viewer splits it as the terminal does (#687); a URL whose row soft-wraps no longer glues a trailing column onto the next path, and in a table a link in the column right of a wrapped URL stays clickable on the URL's first row (#686). The keyboard hold reads the terminal's height only while rows are held (#711). The README, the plugin's README and its marketplace description now say the codeman skill reads saved Codex conversations outside a Codeman session and refuses everything else there (#707).
|
||||
|
||||
**Docs and tests.** New sections for the keyboard hold (next to the terminal geometry rule it is an exception to), the tab +, Git fetch, the settings writer, the tunnel opt-in, the directory spawn, the offline history reader and the experimental agent routes, in CLAUDE.md, the API reference, the architecture notes, the wiki and the codeman skill.
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/Size-5.7%20kB%20gzip-22c55e?style=flat-square" alt="5.7 kB minified and gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-276-22c55e?style=flat-square" alt="276 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
@@ -60,12 +60,13 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
|
||||
| | |
|
||||
|---|---|
|
||||
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own `<span>`, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. |
|
||||
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
|
||||
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
|
||||
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
|
||||
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. |
|
||||
|
||||
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
|
||||
|
||||
@@ -128,7 +129,7 @@ This library answers all three:
|
||||
|
||||
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
|
||||
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
|
||||
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
|
||||
- **one `<span>` per character**, sized to its cells and snapped to the cell grid
|
||||
|
||||
---
|
||||
|
||||
@@ -205,6 +206,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||
| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. |
|
||||
|
||||
### Backspace handling
|
||||
|
||||
@@ -508,15 +510,23 @@ div.xterm-screen (position: relative)
|
||||
|
||||
### Per-character grid alignment
|
||||
|
||||
Each character is an absolutely-positioned `<span>`:
|
||||
Each line is an absolutely-positioned `<div>` at its row, and each character is a `<span>` in a flex row inside it, as wide as the cells it covers:
|
||||
|
||||
```
|
||||
left = visualColumn * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
|
||||
line top = lineIndex * cellHeight (CSS pixels)
|
||||
edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel)
|
||||
span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters)
|
||||
```
|
||||
|
||||
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
|
||||
A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`.
|
||||
|
||||
The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited.
|
||||
|
||||
### Rendering cost
|
||||
|
||||
Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element.
|
||||
|
||||
Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames).
|
||||
|
||||
### Font matching
|
||||
|
||||
@@ -543,7 +553,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px.
|
||||
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
|
||||
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
|
||||
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -46,14 +46,61 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
|
||||
// ─── Overlay rendering ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cell edges are snapped to this fraction of a CSS pixel.
|
||||
*
|
||||
* The glyph spans sit in normal flow, so each one starts where the widths
|
||||
* before it add up to, and a width the layout engine cannot store exactly
|
||||
* would carry its rounding error into every later cell (the drift that plain
|
||||
* DOM text shows against the canvas grid). A quarter pixel is exact in
|
||||
* Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units),
|
||||
* so the widths always add up to the snapped edge: no drift at any column,
|
||||
* and every edge is within 1/8 px of `col * cellW`.
|
||||
*/
|
||||
const EDGE_SNAP = 4;
|
||||
|
||||
function cellEdge(col: number, cellW: number): number {
|
||||
return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP;
|
||||
}
|
||||
|
||||
/** What the last render left in a container, so the next one rebuilds only the lines that changed. */
|
||||
interface RenderedLines {
|
||||
/** Everything a line's markup depends on besides its own text and position. */
|
||||
styleKey: string;
|
||||
lines: HTMLDivElement[];
|
||||
lineKeys: string[];
|
||||
cursor: HTMLSpanElement | null;
|
||||
}
|
||||
|
||||
const rendered = new WeakMap<HTMLDivElement, RenderedLines>();
|
||||
|
||||
/**
|
||||
* False once anything else has emptied or edited the container (the addon's
|
||||
* hide path clears it): the children must be exactly the recorded lines, in
|
||||
* order, then the cursor.
|
||||
*/
|
||||
function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean {
|
||||
const children = container.children;
|
||||
if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false;
|
||||
for (let i = 0; i < state.lines.length; i++) {
|
||||
if (children[i] !== state.lines[i]) return false;
|
||||
}
|
||||
return !state.cursor || children[state.lines.length] === state.cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the overlay content into the container element.
|
||||
*
|
||||
* Creates per-character `<span>` elements positioned on an exact grid
|
||||
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
|
||||
* occurs with normal DOM text flow.
|
||||
* Each line is a `<div>` holding one `<span>` per character on the cell grid
|
||||
* of xterm.js's canvas renderer (see makeLine). CJK wide characters get
|
||||
* double-width spans.
|
||||
*
|
||||
* CJK wide characters are rendered with double-width spans.
|
||||
* Only lines whose content or position changed since the previous render
|
||||
* into the same container are rebuilt. Typing a character changes just the
|
||||
* last line, so a keystroke costs one line of DOM work however long the
|
||||
* prompt is; rebuilding everything made each keystroke O(prompt length) in
|
||||
* DOM work, layout and paint, which a long prompt on a phone paid for in
|
||||
* dropped frames.
|
||||
*/
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
@@ -94,11 +141,14 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
const lineLengths: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
const length = [...line].length;
|
||||
lineOffsets.push(offset);
|
||||
offset += [...line].length;
|
||||
lineLengths.push(length);
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
@@ -120,8 +170,29 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
container.style.left = '0px';
|
||||
container.style.top = topRow * cellH + 'px';
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
// Reuse the previous render's lines when nothing they depend on but their
|
||||
// own text and position changed; otherwise start from an empty container.
|
||||
const styleKey = [
|
||||
cellW,
|
||||
cellH,
|
||||
font.fontFamily,
|
||||
font.fontSize,
|
||||
font.fontWeight,
|
||||
font.color,
|
||||
font.backgroundColor,
|
||||
font.letterSpacing,
|
||||
terminal?.unicode?.activeVersion ?? '',
|
||||
].join('\n');
|
||||
let state = rendered.get(container);
|
||||
if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) {
|
||||
container.innerHTML = '';
|
||||
state = { styleKey, lines: [], lineKeys: [], cursor: null };
|
||||
rendered.set(container, state);
|
||||
}
|
||||
// Taken out while the lines change, so it is appended last again below.
|
||||
state.cursor?.remove();
|
||||
state.cursor = null;
|
||||
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < visibleLines.length; i++) {
|
||||
@@ -129,8 +200,16 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
// Clamped to the line, so a line the composition does not reach keeps
|
||||
// the same key (and its element) while the composition moves along.
|
||||
let lineCompositionFrom: number | undefined;
|
||||
if (compositionStart !== undefined) {
|
||||
const from = compositionStart - lineOffsets[firstVisible + i];
|
||||
if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from);
|
||||
}
|
||||
// Text goes last, so no character in it can make two different lines collide.
|
||||
const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`;
|
||||
if (state.lineKeys[i] === key) continue;
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
@@ -144,8 +223,15 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
container.appendChild(lineEl);
|
||||
const previous = state.lines[i];
|
||||
if (previous) container.replaceChild(lineEl, previous);
|
||||
else container.appendChild(lineEl);
|
||||
state.lines[i] = lineEl;
|
||||
state.lineKeys[i] = key;
|
||||
}
|
||||
for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove();
|
||||
state.lines.length = visibleLines.length;
|
||||
state.lineKeys.length = visibleLines.length;
|
||||
|
||||
// Block cursor at end of last line (use visual width for CJK support)
|
||||
if (showCursor) {
|
||||
@@ -161,6 +247,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
state.cursor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,13 +255,20 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
* Create a styled line `<div>` with one `<span>` per character on the cell grid.
|
||||
*
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
* The spans sit side by side in a flex row, each exactly as wide as its cells
|
||||
* (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the
|
||||
* widths add up to the grid without drifting. They are deliberately not
|
||||
* absolutely positioned: a positioned element gets its own paint layer, and
|
||||
* with one per character a long prompt left the browser re-layerizing and
|
||||
* repainting thousands of them on every keystroke. Everything the spans share
|
||||
* (font, colour, line height, centring, ligatures off) is set once on the
|
||||
* line and inherited.
|
||||
*
|
||||
* Characters at or after `compositionFrom` (a code-point index into `text`)
|
||||
* are IME composition text: underlined, like xterm's own composition view, and
|
||||
* marked `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -190,7 +284,9 @@ function makeLine(
|
||||
compositionFrom?: number
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
// No ligatures: the canvas renders each glyph independently.
|
||||
el.style.cssText =
|
||||
"position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0";
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
@@ -200,27 +296,24 @@ function makeLine(
|
||||
// The extra 1px lands in the next row's charTop gap (empty area before
|
||||
// text rendering starts), so no canvas content is obscured.
|
||||
el.style.height = cellH + 1 + 'px';
|
||||
el.style.lineHeight = cellH + 'px';
|
||||
el.style.fontFamily = font.fontFamily;
|
||||
el.style.fontSize = font.fontSize;
|
||||
el.style.fontWeight = font.fontWeight;
|
||||
el.style.color = font.color;
|
||||
if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing;
|
||||
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
// Each span runs from the previous snapped edge to its own, so the spans
|
||||
// land on the grid by visual column (CJK wide chars span 2 cells).
|
||||
const spanHeight = ';height:' + cellH + 'px';
|
||||
let col = 0;
|
||||
let edge = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
col += charCellWidth(terminal, ch);
|
||||
const nextEdge = cellEdge(col, cellW);
|
||||
const span = document.createElement('span');
|
||||
// No ligatures — canvas renders each glyph independently.
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
"font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = colOffset * cellW + 'px';
|
||||
span.style.top = '0px';
|
||||
span.style.width = cw * cellW + 'px';
|
||||
span.style.height = cellH + 'px';
|
||||
span.style.lineHeight = cellH + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
@@ -228,7 +321,7 @@ function makeLine(
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
edge = nextEdge;
|
||||
index++;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
private _bufferDetectDone = false;
|
||||
// IME text still being composed: drawn after the pending text, never sent.
|
||||
private _composition = '';
|
||||
// Rows from the top of the screen that are actually visible (see setRowLimit);
|
||||
// null means all of them.
|
||||
private _rowLimit: number | null = null;
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
@@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the layout to the first `limit` rows of the screen, or to all of them
|
||||
* with `null`. It narrows the bound the overlay already keeps itself on
|
||||
* screen with (`totalRows`, see _layoutRows), never adds a second one.
|
||||
*
|
||||
* For a host that keeps the terminal taller than the box showing it and
|
||||
* scrolls the screen element instead (e.g. holding the PTY's rows while an
|
||||
* on-screen keyboard covers the bottom): the composer must grow upward from
|
||||
* the last row the user can SEE, not from the terminal's last row, which is
|
||||
* under the keyboard.
|
||||
*/
|
||||
setRowLimit(limit: number | null): void {
|
||||
const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null;
|
||||
if (next === this._rowLimit) return;
|
||||
this._rowLimit = next;
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `totalRows` bound handed to renderOverlay: the one place that decides
|
||||
* where the block stops growing downward. The terminal's rows, narrowed by
|
||||
* setRowLimit when the host shows fewer; a limit above the terminal's rows
|
||||
* changes nothing.
|
||||
*/
|
||||
private _layoutRows(): number {
|
||||
const rows = this._terminal!.rows;
|
||||
return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read font properties from the terminal and re-render.
|
||||
* Call after font size changes, theme changes, etc.
|
||||
@@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
// (see renderOverlay), so a keyboard opening, which changes rows or the
|
||||
// row limit without changing the text, must not be skipped as a
|
||||
// redundant render.
|
||||
const totalRows = this._layoutRows();
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
charTop,
|
||||
charHeight,
|
||||
promptRow: activePrompt.row,
|
||||
totalRows: this._terminal.rows,
|
||||
totalRows,
|
||||
font: this._font,
|
||||
showCursor: this._options.showCursor,
|
||||
cursorColor,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { createMockTerminal, lineSpans } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// ─── Shared fixtures ─────────────────────────────────────────────────
|
||||
@@ -47,16 +47,7 @@ function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||
|
||||
/** Extract span data from a rendered line div */
|
||||
function getSpans(lineDiv: HTMLDivElement) {
|
||||
const spans: { text: string; left: number; width: number }[] = [];
|
||||
for (let i = 0; i < lineDiv.children.length; i++) {
|
||||
const span = lineDiv.children[i] as HTMLSpanElement;
|
||||
spans.push({
|
||||
text: span.textContent || '',
|
||||
left: parseFloat(span.style.left),
|
||||
width: parseFloat(span.style.width),
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width }));
|
||||
}
|
||||
|
||||
// ─── Addon setup helpers ─────────────────────────────────────────────
|
||||
@@ -199,18 +190,27 @@ describe('Test 2: CJK double-width spacing', () => {
|
||||
|
||||
it('CJK span widths are exactly 2 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
|
||||
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
|
||||
expect(span.style.width).toBe('17px'); // 2 * 8.5
|
||||
});
|
||||
|
||||
it('ASCII span widths remain 1 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
|
||||
expect(span.style.width).toBe('8.5px'); // 1 * 8.5
|
||||
});
|
||||
|
||||
it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 }));
|
||||
const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement);
|
||||
expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
});
|
||||
|
||||
it('terminal unicode addon is preferred over fallback when available', () => {
|
||||
@@ -644,8 +644,9 @@ describe('Test 6: ASCII input — no regression', () => {
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans[0].left).toBe(0);
|
||||
expect(spans[1].left).toBeCloseTo(8.4, 5);
|
||||
expect(spans[2].left).toBeCloseTo(16.8, 5);
|
||||
// Edges are snapped to a quarter pixel, so within 1/8 px of the grid.
|
||||
expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125);
|
||||
});
|
||||
|
||||
it('ASCII cursor positions at correct column', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { createMockTerminal, lineSpans } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||
@@ -68,8 +68,9 @@ describe('setComposition', () => {
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
}
|
||||
// Grid positions continue straight on from the pending text.
|
||||
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||
const cells = lineSpans(line);
|
||||
expect(cells[3].left).toBe(3 * CELL_W);
|
||||
expect(cells[4].left).toBe(4 * CELL_W);
|
||||
expect(overlay.style.display).toBe('');
|
||||
});
|
||||
|
||||
@@ -79,9 +80,10 @@ describe('setComposition', () => {
|
||||
addon.setComposition('天気');
|
||||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||
const cells = lineSpans(lineDivs(overlay)[0]);
|
||||
expect(cells[3].left).toBe(6 * CELL_W);
|
||||
expect(cells[3].width).toBe(2 * CELL_W);
|
||||
expect(cells[4].left).toBe(8 * CELL_W);
|
||||
});
|
||||
|
||||
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||
@@ -115,7 +117,8 @@ describe('setComposition', () => {
|
||||
expect(compositionText(overlay)).toBe('WXYZ');
|
||||
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||
expect(second[0].style.left).toBe('0px');
|
||||
expect(lineSpans(lines[1])[0].left).toBe(0);
|
||||
expect(lines[1].style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||
|
||||
@@ -204,3 +204,21 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The glyph spans of a rendered overlay line, with each one's left edge and
|
||||
* width in px. The spans sit side by side in a flex row, so a span's left
|
||||
* edge is the sum of the widths before it (what the browser lays out), not a
|
||||
* style property of its own; jsdom does no layout, so this adds them up.
|
||||
*/
|
||||
export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] {
|
||||
const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = [];
|
||||
let left = 0;
|
||||
for (const child of Array.from(lineDiv.children)) {
|
||||
const el = child as HTMLSpanElement;
|
||||
const width = parseFloat(el.style.width);
|
||||
spans.push({ text: el.textContent || '', left, width, el });
|
||||
left += width;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
import { lineSpans } from './helpers.js';
|
||||
|
||||
const FONT: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
@@ -38,7 +39,7 @@ describe('renderOverlay', () => {
|
||||
|
||||
it('creates per-character spans in a line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 }));
|
||||
|
||||
// Line div + cursor span
|
||||
expect(container.children.length).toBe(2);
|
||||
@@ -46,17 +47,43 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3); // a, b, c
|
||||
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
const [a, b, c] = lineSpans(lineDiv);
|
||||
expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]);
|
||||
expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]);
|
||||
expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]);
|
||||
});
|
||||
|
||||
const spanB = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('8.4px');
|
||||
it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.position).toBe('absolute');
|
||||
expect(lineDiv.style.display).toBe('flex');
|
||||
for (const { el } of lineSpans(lineDiv)) {
|
||||
expect(el.style.position).toBe('');
|
||||
expect(el.style.left).toBe('');
|
||||
// flex: none, so a span is never stretched or squeezed off its cells.
|
||||
expect(el.style.flexGrow).toBe('0');
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
const spanC = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanC.textContent).toBe('c');
|
||||
expect(spanC.style.left).toBe('16.8px');
|
||||
it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => {
|
||||
const container = document.createElement('div');
|
||||
const cellW = 8.4;
|
||||
renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW }));
|
||||
const spans = lineSpans(container.children[0]);
|
||||
let col = 0;
|
||||
for (const span of spans) {
|
||||
// Every width is a whole number of quarter pixels, which Blink, WebKit
|
||||
// and Gecko all store exactly, so the browser adds them up exactly too.
|
||||
expect(Number.isInteger(span.width * 4)).toBe(true);
|
||||
expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125);
|
||||
col += span.text === '你' ? 2 : 1;
|
||||
}
|
||||
// The far edge is still on the grid after 80 columns.
|
||||
const last = spans[spans.length - 1];
|
||||
expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125);
|
||||
});
|
||||
|
||||
it('sets span width to cellW', () => {
|
||||
@@ -67,7 +94,7 @@ describe('renderOverlay', () => {
|
||||
expect(span.style.width).toBe('9.5px');
|
||||
});
|
||||
|
||||
it('applies font styles to spans', () => {
|
||||
it('applies font styles once on the line, for its spans to inherit', () => {
|
||||
const font: FontStyle = {
|
||||
fontFamily: 'Fira Code',
|
||||
fontSize: '16px',
|
||||
@@ -83,12 +110,16 @@ describe('renderOverlay', () => {
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
|
||||
|
||||
expect(lineDiv.style.fontFamily).toBe('Fira Code');
|
||||
expect(lineDiv.style.fontSize).toBe('16px');
|
||||
expect(lineDiv.style.fontWeight).toBe('bold');
|
||||
expect(lineDiv.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(lineDiv.style.letterSpacing).toBe('0.5px');
|
||||
|
||||
// Inherited, so a span sets none of them itself.
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.fontFamily).toBe('Fira Code');
|
||||
expect(span.style.fontSize).toBe('16px');
|
||||
expect(span.style.fontWeight).toBe('bold');
|
||||
expect(span.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(span.style.letterSpacing).toBe('0.5px');
|
||||
expect(span.style.fontFamily).toBe('');
|
||||
expect(span.style.color).toBe('');
|
||||
});
|
||||
|
||||
it('offsets first line by startCol', () => {
|
||||
@@ -217,47 +248,49 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.height).toBe('19px');
|
||||
expect(span.style.lineHeight).toBe('19px');
|
||||
// Set on the line and inherited by the span.
|
||||
expect(lineDiv.style.lineHeight).toBe('19px');
|
||||
});
|
||||
|
||||
it('span top is 0px (no vertical offset / no transform)', () => {
|
||||
it('span has no vertical offset and no transform', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.top).toBe('0px');
|
||||
// A flex item starts at the top of its line; nothing shifts it down.
|
||||
expect(span.style.top).toBe('');
|
||||
expect(span.style.marginTop).toBe('');
|
||||
// No translateY transform — sub-pixel overhang causes artifacts
|
||||
expect(span.style.transform).toBe('');
|
||||
});
|
||||
|
||||
// ─── Font rendering tests ─────────────────────────────────────
|
||||
|
||||
it('span disables ligatures via font-feature-settings', () => {
|
||||
// These are inherited properties, set once on the line for every span.
|
||||
|
||||
it('line disables ligatures via font-feature-settings', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['fi'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
// Check cssText includes the ligature-disabling settings
|
||||
// jsdom may normalize whitespace; check that both liga and calt are disabled
|
||||
expect(span.style.cssText).toContain('font-feature-settings:');
|
||||
expect(span.style.cssText).toContain("'liga' 0");
|
||||
expect(span.style.cssText).toContain("'calt' 0");
|
||||
expect(lineDiv.style.cssText).toContain('font-feature-settings:');
|
||||
expect(lineDiv.style.cssText).toContain("'liga' 0");
|
||||
expect(lineDiv.style.cssText).toContain("'calt' 0");
|
||||
});
|
||||
|
||||
it('span has text-align: center for glyph centering', () => {
|
||||
it('line has text-align: center for glyph centering', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['m'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.textAlign).toBe('center');
|
||||
expect(lineDiv.style.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('span has pointer-events: none', () => {
|
||||
it('line has pointer-events: none', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.pointerEvents).toBe('none');
|
||||
expect(lineDiv.style.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
// ─── Multi-line cursor positioning ────────────────────────────
|
||||
@@ -323,20 +356,18 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3);
|
||||
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
expect(spanA.style.width).toBe('10px'); // 1 cell
|
||||
const [spanA, spanCJK, spanB] = lineSpans(lineDiv);
|
||||
expect(spanA.text).toBe('a');
|
||||
expect(spanA.left).toBe(0);
|
||||
expect(spanA.width).toBe(10); // 1 cell
|
||||
|
||||
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanCJK.textContent).toBe('你');
|
||||
expect(spanCJK.style.left).toBe('10px'); // col 1
|
||||
expect(spanCJK.style.width).toBe('20px'); // 2 cells
|
||||
expect(spanCJK.text).toBe('你');
|
||||
expect(spanCJK.left).toBe(10); // col 1
|
||||
expect(spanCJK.width).toBe(20); // 2 cells
|
||||
|
||||
const spanB = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('30px'); // col 3
|
||||
expect(spanB.style.width).toBe('10px'); // 1 cell
|
||||
expect(spanB.text).toBe('b');
|
||||
expect(spanB.left).toBe(30); // col 3
|
||||
expect(spanB.width).toBe(10); // 1 cell
|
||||
});
|
||||
|
||||
it('cursor position accounts for CJK width', () => {
|
||||
@@ -360,13 +391,11 @@ describe('renderOverlay', () => {
|
||||
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// h(col 0), i(col 1), 你(col 2, width 2)
|
||||
const spanH = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanH.style.left).toBe('0px');
|
||||
const spanI = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanI.style.left).toBe('8px');
|
||||
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanCJK.style.left).toBe('16px');
|
||||
expect(spanCJK.style.width).toBe('16px');
|
||||
const [spanH, spanI, spanCJK] = lineSpans(lineDiv);
|
||||
expect(spanH.left).toBe(0);
|
||||
expect(spanI.left).toBe(8);
|
||||
expect(spanCJK.left).toBe(16);
|
||||
expect(spanCJK.width).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,3 +532,147 @@ describe('renderOverlay — staying on screen (totalRows)', () => {
|
||||
expect(container.style.top).toBe(6 * 17 + 'px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderOverlay: rebuilding only the lines that changed', () => {
|
||||
const lineEls = (container: HTMLDivElement) =>
|
||||
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join('');
|
||||
|
||||
it('appending to the last line keeps every earlier line element', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] }));
|
||||
const [first, second, third] = lineEls(container);
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] }));
|
||||
const after = lineEls(container);
|
||||
expect(after[0]).toBe(first);
|
||||
expect(after[1]).toBe(second);
|
||||
expect(after[2]).not.toBe(third);
|
||||
expect(after.map(text)).toEqual(['first', 'second', 'thi']);
|
||||
});
|
||||
|
||||
it('keeps the cursor as the last child and moves it with the text', () => {
|
||||
const container = document.createElement('div');
|
||||
const params = { startCol: 0, cellW: 10 };
|
||||
renderOverlay(container, makeParams({ ...params, lines: ['ab'] }));
|
||||
renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] }));
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
expect(children.length).toBe(3);
|
||||
const cursor = children[2];
|
||||
expect(cursor.tagName).toBe('SPAN');
|
||||
expect(cursor.style.left).toBe('10px');
|
||||
expect(cursor.style.top).toBe(17 + 'px');
|
||||
expect(container.querySelectorAll(':scope > span').length).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrapped line coming back (backspace) removes the extra line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc', 'd'] }));
|
||||
const [first] = lineEls(container);
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
expect(lineEls(container)).toEqual([first]);
|
||||
expect(container.children.length).toBe(2); // line + cursor
|
||||
});
|
||||
|
||||
it('removes the cursor when the next render does not show one', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'] }));
|
||||
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
|
||||
expect(container.children.length).toBe(1);
|
||||
expect(container.children[0].tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('a font, colour or cell size change rebuilds every line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
const before = lineEls(container);
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } }));
|
||||
const recoloured = lineEls(container);
|
||||
expect(recoloured[0]).not.toBe(before[0]);
|
||||
expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)');
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 }));
|
||||
const resized = lineEls(container);
|
||||
expect(resized[1]).not.toBe(recoloured[1]);
|
||||
expect(lineSpans(resized[1])[1].left).toBe(12);
|
||||
});
|
||||
|
||||
it('a Unicode version switch rebuilds lines whose text did not change', () => {
|
||||
const container = document.createElement('div');
|
||||
const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) };
|
||||
const terminal = { unicode } as any;
|
||||
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
|
||||
const before = lineEls(container)[0];
|
||||
|
||||
unicode.activeVersion = '11';
|
||||
unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1);
|
||||
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
|
||||
const after = lineEls(container)[0];
|
||||
expect(after).not.toBe(before);
|
||||
expect(lineSpans(after)[1].width).toBe(20);
|
||||
});
|
||||
|
||||
it('starts over when something else emptied the container', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
container.innerHTML = '';
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
|
||||
expect(container.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it('starts over when something else reordered the lines', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
const [one, two] = lineEls(container);
|
||||
container.insertBefore(two, one);
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
|
||||
expect(container.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it('starts over when something else added to the container', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one'] }));
|
||||
container.appendChild(document.createElement('div'));
|
||||
renderOverlay(container, makeParams({ lines: ['one'] }));
|
||||
expect(container.children.length).toBe(2);
|
||||
expect(text(lineEls(container)[0])).toBe('one');
|
||||
});
|
||||
|
||||
it('a composition growing on the last line leaves the lines it does not reach alone', () => {
|
||||
const container = document.createElement('div');
|
||||
// 'first' (5) + 'abc' pending, composition starts at code point 8.
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 }));
|
||||
const [first] = lineEls(container);
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 }));
|
||||
const after = lineEls(container);
|
||||
expect(after[0]).toBe(first);
|
||||
const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition'));
|
||||
expect(marked.map((s) => s.textContent).join('')).toBe('xy');
|
||||
});
|
||||
|
||||
it('a composition that starts on an earlier line marks the whole later line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 }));
|
||||
const [first, second] = lineEls(container);
|
||||
const marked = (line: HTMLDivElement) =>
|
||||
Array.from(line.children)
|
||||
.filter((s) => s.hasAttribute('data-zerolag-composition'))
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
expect(marked(first)).toBe('XY');
|
||||
expect(marked(second)).toBe('ZW');
|
||||
});
|
||||
|
||||
it('two containers keep separate records', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
renderOverlay(a, makeParams({ lines: ['same'] }));
|
||||
renderOverlay(b, makeParams({ lines: ['same'] }));
|
||||
expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]);
|
||||
expect(text(lineEls(a)[0])).toBe('same');
|
||||
expect(text(lineEls(b)[0])).toBe('same');
|
||||
});
|
||||
});
|
||||
@@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => {
|
||||
expect(overlay.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRowLimit', () => {
|
||||
function rowLimitSetup(promptRow = 10) {
|
||||
const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : ''));
|
||||
const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 });
|
||||
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
|
||||
return { addon, overlay };
|
||||
}
|
||||
|
||||
it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => {
|
||||
const { addon, overlay } = rowLimitSetup();
|
||||
addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10
|
||||
expect(overlay().style.top).toBe(`${10 * 17}px`);
|
||||
addon.setRowLimit(12);
|
||||
expect(overlay().style.top).toBe(`${9 * 17}px`);
|
||||
});
|
||||
|
||||
it('null restores the full terminal height, and junk reads as null', () => {
|
||||
const { addon, overlay } = rowLimitSetup();
|
||||
addon.appendText('x'.repeat(50));
|
||||
addon.setRowLimit(12);
|
||||
addon.setRowLimit(null);
|
||||
expect(overlay().style.top).toBe(`${10 * 17}px`);
|
||||
addon.setRowLimit(12);
|
||||
addon.setRowLimit(Number.NaN);
|
||||
expect(overlay().style.top).toBe(`${10 * 17}px`);
|
||||
});
|
||||
|
||||
// setRowLimit narrows the SAME bound renderOverlay already keeps the block on
|
||||
// screen with (RenderParams.totalRows, filled from terminal.rows), so there is
|
||||
// one place that decides where the block stops growing downward.
|
||||
it('narrows the terminal-rows bound and never widens it', () => {
|
||||
const { addon, overlay } = rowLimitSetup(22);
|
||||
addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21
|
||||
expect(overlay().style.top).toBe(`${21 * 17}px`);
|
||||
addon.setRowLimit(24);
|
||||
expect(overlay().style.top).toBe(`${21 * 17}px`);
|
||||
addon.setRowLimit(100);
|
||||
expect(overlay().style.top).toBe(`${21 * 17}px`);
|
||||
addon.setRowLimit(23);
|
||||
expect(overlay().style.top).toBe(`${20 * 17}px`);
|
||||
});
|
||||
|
||||
it('leaves a block that fits above the limit where it is', () => {
|
||||
const { addon, overlay } = rowLimitSetup();
|
||||
addon.appendText('x'.repeat(50));
|
||||
addon.setRowLimit(13); // rows 10..12 are exactly the three lines
|
||||
expect(overlay().style.top).toBe(`${10 * 17}px`);
|
||||
});
|
||||
|
||||
it('keeps the TAIL of a block taller than the limit', () => {
|
||||
const { addon, overlay } = rowLimitSetup();
|
||||
addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters
|
||||
addon.setRowLimit(2);
|
||||
expect(overlay().style.top).toBe('0px');
|
||||
const xs = [...overlay().querySelectorAll(':scope > div')].map(
|
||||
(line) => (line.textContent ?? '').replace(/[^x]/g, '').length
|
||||
);
|
||||
expect(xs).toEqual([20, 12]);
|
||||
});
|
||||
|
||||
it('holds across later keystrokes, not only the re-render it triggers', () => {
|
||||
const { addon, overlay } = rowLimitSetup();
|
||||
addon.appendText('x'.repeat(50));
|
||||
addon.setRowLimit(12);
|
||||
addon.appendText('y');
|
||||
expect(overlay().style.top).toBe(`${9 * 17}px`);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.45.0",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.48.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -7,7 +7,7 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
|
||||
/plugin install codeman@codeman
|
||||
```
|
||||
|
||||
The skill acts only inside a Codeman-managed session (`CODEMAN_MUX=1`) and refuses everywhere else, so installing it globally costs nothing for unrelated sessions.
|
||||
The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
|
||||
|
||||
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
|
||||
|
||||
|
||||
@@ -8,12 +8,19 @@ description: >-
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
another session, start and manage workers, or retrieve saved local Codex history
|
||||
by file/native ID or root and descendants with topic/date filters. Live API
|
||||
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
|
||||
reads do not require a server or managed session.
|
||||
---
|
||||
|
||||
# Driving Codeman from inside a session
|
||||
|
||||
For **saved Codex conversation history**, use `codeman history read` and
|
||||
[reference/history.md](reference/history.md). This is a read-only local command:
|
||||
route to it **before §0**, including outside a managed session. It needs no API
|
||||
URL, credentials or running server. All live API operations below retain §0.
|
||||
|
||||
You are an agent running inside a Codeman-managed terminal session. Codeman is the
|
||||
server that spawned you; its HTTP API can start, prompt, watch, and delete other
|
||||
sessions.
|
||||
@@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
|
||||
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
Experimental bounded inspection and guarded stage/submit load only when needed from
|
||||
[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
|
||||
|
||||
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
|
||||
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
|
||||
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
|
||||
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox
|
||||
verbs `post`, `inbox`, `ack`) is the same
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
|
||||
`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
|
||||
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
|
||||
no guessed URL) enforced in code, and it is also your own way through when this
|
||||
machine has no `jq` for the preamble below.
|
||||
|
||||
For an existing project, use one command:
|
||||
`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
|
||||
no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
|
||||
positional case keeps quick-start behavior and cannot be combined with
|
||||
`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
|
||||
rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
|
||||
land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
|
||||
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
|
||||
reports the exact created ID: inspect it before retrying or cleaning up.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
you are not part of is not yours to drive.
|
||||
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
|
||||
Do not guess an API URL; a server you are not part of is not yours to drive.
|
||||
|
||||
Two failures come from your CLI, not from Codeman: name them to the user instead of
|
||||
retrying.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Saved Codex history (offline)
|
||||
|
||||
Use `codeman history read` for retained local Codex JSONL transcripts. This command
|
||||
works outside a managed session and never calls the HTTP API. Do not bootstrap
|
||||
API credentials or require `CODEMAN_MUX` for this read-only job.
|
||||
|
||||
Choose exactly one selector:
|
||||
|
||||
```bash
|
||||
codeman history read --file /path/to/rollout.jsonl
|
||||
codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
|
||||
codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
|
||||
--topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
|
||||
--max-bytes 65536
|
||||
```
|
||||
|
||||
`--session-id` accepts a native conversation UUID or unique prefix of at least
|
||||
8 characters; identity comes from metadata, not filenames. `--root-session-id`
|
||||
requires a full native UUID and includes transitive descendants found in metadata
|
||||
(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
|
||||
`~/.codex/sessions` when unset; a store directory that is itself a symlink is
|
||||
followed, links inside it are skipped. `--file` selects one file and does not
|
||||
search for its ancestors or children.
|
||||
|
||||
Topic matching is a case-insensitive literal substring. Dates are inclusive
|
||||
`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
|
||||
user/assistant turns; a turn is retained if it has a date match and a topic match,
|
||||
which may be different messages in that turn. Tool calls/results and injected
|
||||
context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
|
||||
`<environment_context>`) are omitted. Adjacent duplicate native representations
|
||||
are collapsed; repeated messages from the same representation are retained.
|
||||
|
||||
A forked or spawned thread's file starts with a copy of its parent's history. That
|
||||
copy is dropped (it is in the parent's own file), so each record belongs to the
|
||||
thread that wrote it. A later `session_meta` for any thread that is not a declared
|
||||
ancestor still refuses the file.
|
||||
|
||||
JSON is the default (`--json` or `--format json`); `--format text` prints one block
|
||||
per record. `--json` together with `--format text` is an error.
|
||||
Results identify source paths, native session/root IDs, timestamps, roles and phases,
|
||||
plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
|
||||
`final_answer`) on assistant records and `null` on user records or when none was
|
||||
recorded. A root ID is `null` when ancestry cannot be proven.
|
||||
Report that uncertainty; never relabel the queried child as its own root.
|
||||
`complete: false` reports malformed or partial records (a running Codex may be
|
||||
mid-write), skipped links, unattributed files, compressed rollouts, unreadable
|
||||
directories, missing ancestors, or an inherited copy whose end could not be found
|
||||
or proven (including when the parent's file is present but cannot be read). It
|
||||
does not prove the store includes every conversation ever created, and a filtered
|
||||
result is only evidence for its selected scope.
|
||||
|
||||
Reads require regular UTF8 files and reject final-component symlinks, conflicting
|
||||
metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
|
||||
cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
|
||||
changing while its head is read) is counted in `unattributedFiles` instead of
|
||||
failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
|
||||
`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
|
||||
plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
|
||||
misses names them. A compressed file passed to `--file` is refused; read a
|
||||
decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout
|
||||
that a running Codex is appending to is read as a snapshot up to its last complete
|
||||
line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
|
||||
not a filesystem sandbox.
|
||||
|
||||
Budgets, each failing with an error rather than a partial answer. Every flag is
|
||||
validated before the first file is read, and only plain decimal integers are
|
||||
accepted:
|
||||
|
||||
| Flag | Bounds | Default | Maximum |
|
||||
| ---- | ------ | ------- | ------- |
|
||||
| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
|
||||
| `--max-entries` | directory entries visited | 65536 | 1048576 |
|
||||
| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
|
||||
| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
|
||||
|
||||
Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
|
||||
attributing a store costs about that much per rollout, charged to
|
||||
`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
|
||||
writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
|
||||
JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
|
||||
Missing evidence is an error, not an empty success.
|
||||
|
||||
This command does not inspect live panes, stage prompts, submit input or manage
|
||||
sessions. For live operations return to the managed-session guard in the skill.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Experimental bounded inspection and guarded submission
|
||||
|
||||
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
|
||||
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
|
||||
are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
|
||||
payloads and behavior may change or be removed in a minor or patch release.
|
||||
Pin a compatible Codeman version when automating them. Authentication and
|
||||
session ownership checks still apply.
|
||||
|
||||
They are also opt-in: until the user turns on **Experimental agent coordination**
|
||||
in App Settings (`agentCoordinationEnabled`, off by default), every one of them
|
||||
fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
|
||||
to retry or work around: tell the user, or use `send`/`read` instead.
|
||||
|
||||
Use these commands inside a Codeman session. They use its authenticated HTTP API.
|
||||
They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
|
||||
tmux access is required.
|
||||
|
||||
## Inspect recent evidence
|
||||
|
||||
```bash
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
|
||||
```
|
||||
|
||||
Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
|
||||
interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
|
||||
metadata/envelope. The default is 8192, maximum 65536.
|
||||
|
||||
- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
|
||||
selects recent complete records within a bounded tail window;
|
||||
`omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
|
||||
after the last consumed complete record. Partial final records remain pending.
|
||||
- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
|
||||
output. It has `incremental:false` and `cursor:null`. This is the fallback for
|
||||
other modes, remote/container sessions, ambiguous attribution, and exhausted
|
||||
attribution budgets. Repeating a snapshot can repeat evidence.
|
||||
|
||||
Attribution proves which file supplied the evidence, not which thread is now
|
||||
active in the TUI. A launch resume that conflicts with a newly owned rollout
|
||||
falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
|
||||
thread may leave launch metadata unchanged. Check `identity.conversationId`;
|
||||
these reads do not provide authoritative current-thread tracking.
|
||||
|
||||
Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
|
||||
requesting identity, session, pane lifecycle generation
|
||||
and attributed transcript, expire after 15 minutes, and disappear on server
|
||||
restart. Each caller and session keeps its newest 8 (so retrying the last call
|
||||
works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
|
||||
or unavailable native source returns `409 CONFLICT`. Inspect again without
|
||||
`--since` to establish a new tail baseline; this may omit older evidence.
|
||||
|
||||
`blockedReason` explains an unfinished or oversized record. Incremental reads
|
||||
never split or silently skip one. Increase the byte budget for
|
||||
`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
|
||||
use `read --tail` for a labeled terminal view or start a fresh tail baseline.
|
||||
Inspect scans at most 256 KiB of record data plus bounded generation anchors.
|
||||
Attribution separately caps directory entries (8192), depth (8), negative-filter
|
||||
prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
|
||||
activity are observations, not proof of task completion or authorization.
|
||||
|
||||
## Stage, inspect, then submit once
|
||||
|
||||
```bash
|
||||
printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
|
||||
codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
|
||||
# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
|
||||
```
|
||||
|
||||
Stage types the file contents **without Enter**. The file must be regular, UTF8,
|
||||
one printable line, at most 8192 bytes, with no control characters or trailing
|
||||
whitespace/newline. It initially supports a local Codex composer with a recognized
|
||||
footer and an exact, unwrapped line/caret capture. Before typing, stage checks
|
||||
the actual captured width and composer indentation; a message that would reach
|
||||
the right edge or wrap is refused without writing anything. Unknown, working, modal,
|
||||
wrapped, remote/container and shell composers are refused. A customized footer or
|
||||
unsupported character-width rendering can also make verification unavailable.
|
||||
Self-targets are refused by the CLI.
|
||||
|
||||
Stage also refuses, before typing, a message that would change what Enter does:
|
||||
one starting with `/` (a Codex slash command; with its command list open, Enter
|
||||
would run the highlighted command instead) or `!` (a shell command), and one whose
|
||||
**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
|
||||
follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
|
||||
is fine. Send slash and shell commands with `codeman agent send`, and end a
|
||||
message with an ordinary word when it mentions a file. A frame showing a
|
||||
suggestion popup above the composer is refused at stage and submit too.
|
||||
|
||||
The receipt stores a hash and ephemeral identities, never prompt content. It
|
||||
expires after two minutes; capacity is 1024 live receipts. After typing, stage
|
||||
re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
|
||||
after the keys) and issues a receipt only for a frame that shows exactly the
|
||||
message with the caret after it. Any refusal after the keys went out (typing
|
||||
failed or was ambiguous, the pane or input changed, or no frame verified in time)
|
||||
says the message may already be in the composer: no receipt was issued. Do not
|
||||
stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
|
||||
|
||||
While a submit's Enter is in flight (milliseconds, plus the moment it takes to
|
||||
write input held behind it), TAGGED input to that session (`clientId` and `seq`,
|
||||
which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
|
||||
with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
|
||||
and Codeman's own managed writes are held instead and written in arrival order
|
||||
once the Enter is out.
|
||||
|
||||
Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
|
||||
geometry, exact composer and caret, then sends one conditional Enter at the session's input
|
||||
boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
|
||||
Enter that follows typed text within 120 ms into a newline), so submitting right
|
||||
after stage is fine. Competing Codeman writes are held during dispatch and written
|
||||
after it, except tagged input, which is refused with `409`. Browser/API/key
|
||||
input and managed restarts invalidate receipts even when the visible text or pid
|
||||
is restored. Direct tmux edits are outside that input serialization; captures
|
||||
remain evidence at the time observed, rather than terminal isolation.
|
||||
|
||||
Every owned submit attempt consumes its receipt, including hash mismatch,
|
||||
verification failure and delivery failure. Concurrent/repeated submits cannot
|
||||
replay Enter, and there is no autonomous retry. A successful response means one
|
||||
Enter was delivered; use inspection or a suitable wait signal to observe native
|
||||
acceptance. A `409` or broken connection means inspect first. Do not automatically
|
||||
stage/submit again or send an unguarded Enter to recover.
|
||||
|
||||
## Recover a stranded draft
|
||||
|
||||
A consumed receipt cannot be reused, and a stage refused after typing issued
|
||||
none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
|
||||
(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
|
||||
accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
|
||||
do not clear or resend anything. A transcript prompt row alone is not proof
|
||||
that text remains unsent in the current composer.
|
||||
|
||||
Only recover an unambiguously unsent draft in an input-ready local Codex
|
||||
composer: verify the complete draft, recognized footer and caret at its end in
|
||||
the current frame, and ensure no other writer can edit this target during
|
||||
recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
|
||||
it in the terminal instead of guessing keys. Never use `send` to overwrite a
|
||||
draft: that appends and submits both prompts together.
|
||||
|
||||
After those checks, the known Codex line-clear chord may be sent once through
|
||||
the existing raw input route, with **no Enter**. This conditional example uses
|
||||
the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
|
||||
```
|
||||
|
||||
Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
|
||||
verify the composer is empty and input-ready. Only then create a **fresh** stage
|
||||
receipt for the intended prompt and submit that receipt once. If the draft did
|
||||
not clear, the target began working, or verification remains uncertain, stop and
|
||||
ask the user; do not send an unguarded Enter or replay the old prompt.
|
||||
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
||||
|
||||
| Job | Channel |
|
||||
| --- | --- |
|
||||
| spawn a worker, create its case | HTTP `quick-start` (the only path) |
|
||||
| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
|
||||
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
|
||||
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
|
||||
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
|
||||
|
||||
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|
||||
|-------------------|------|------------------------------|
|
||||
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
|
||||
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
|
||||
|
||||
Read `.data.casePath` back from the `quick-start` response and check it is where you
|
||||
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
|
||||
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
|
||||
that half is the CLI's behavior, not something this API guarantees.
|
||||
|
||||
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
|
||||
typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
|
||||
with `{"input":"\r"}` and let the worker read the junk line.
|
||||
typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
|
||||
draft merely to clear it. For a refused guarded local Codex stage or submit, use the
|
||||
verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
|
||||
when the composer is unclear, ask the user to clear it in the terminal.
|
||||
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
|
||||
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
|
||||
allowlist is S-Enter / C-Enter only.
|
||||
@@ -811,3 +813,16 @@ Rules that matter:
|
||||
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
|
||||
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
|
||||
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
|
||||
|
||||
## Experimental bounded inspection and guarded submission
|
||||
|
||||
These three commands and their HTTP routes are labeled experimental, so they may
|
||||
change or be removed in minor or patch releases. They are also opt-in
|
||||
(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
|
||||
it on). Use `codeman agent inspect` for bounded evidence and incremental local
|
||||
Codex cursors.
|
||||
Use `codeman agent stage --message-file` followed by one hash-confirmed
|
||||
`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
|
||||
composer and caret can be verified. Other targets provide labeled snapshots or
|
||||
refuse guarded input. See [inspection and staging](inspection-and-staging.md)
|
||||
for budgets, receipt expiry, conflict handling and supported composers.
|
||||
+25
-6
@@ -8,12 +8,19 @@ description: >-
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
another session, start and manage workers, or retrieve saved local Codex history
|
||||
by file/native ID or root and descendants with topic/date filters. Live API
|
||||
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
|
||||
reads do not require a server or managed session.
|
||||
---
|
||||
|
||||
# Driving Codeman from inside a session
|
||||
|
||||
For **saved Codex conversation history**, use `codeman history read` and
|
||||
[reference/history.md](reference/history.md). This is a read-only local command:
|
||||
route to it **before §0**, including outside a managed session. It needs no API
|
||||
URL, credentials or running server. All live API operations below retain §0.
|
||||
|
||||
You are an agent running inside a Codeman-managed terminal session. Codeman is the
|
||||
server that spawned you; its HTTP API can start, prompt, watch, and delete other
|
||||
sessions.
|
||||
@@ -30,21 +37,33 @@ through: the verbs in detail (the old §5) in [reference/verbs.md](reference/ver
|
||||
worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpoint
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
Experimental bounded inspection and guarded stage/submit load only when needed from
|
||||
[reference/inspection-and-staging.md](reference/inspection-and-staging.md).
|
||||
|
||||
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
|
||||
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
|
||||
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
|
||||
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`, and the inbox
|
||||
verbs `post`, `inbox`, `ack`) is the same
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `inspect`, `stage`, `submit`,
|
||||
`interrupt`, `rm`, and the inbox verbs `post`, `inbox`, `ack`) is the same
|
||||
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
|
||||
no guessed URL) enforced in code, and it is also your own way through when this
|
||||
machine has no `jq` for the preamble below.
|
||||
|
||||
For an existing project, use one command:
|
||||
`codeman agent spawn --working-dir /path/to/project --mode codex`. With no case and
|
||||
no `--working-dir`, `spawn` uses cwd. Give editing workers separate worktrees. A
|
||||
positional case keeps quick-start behavior and cannot be combined with
|
||||
`--working-dir`; an empty `--working-dir` (`"$WT"` unset) refuses with exit 4
|
||||
rather than falling back to cwd, and a cwd that is HOME refuses too (hooks would
|
||||
land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep parent lineage and never
|
||||
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
|
||||
reports the exact created ID: inspect it before retrying or cleaning up.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
you are not part of is not yours to drive.
|
||||
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
|
||||
Do not guess an API URL; a server you are not part of is not yours to drive.
|
||||
|
||||
Two failures come from your CLI, not from Codeman: name them to the user instead of
|
||||
retrying.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Saved Codex history (offline)
|
||||
|
||||
Use `codeman history read` for retained local Codex JSONL transcripts. This command
|
||||
works outside a managed session and never calls the HTTP API. Do not bootstrap
|
||||
API credentials or require `CODEMAN_MUX` for this read-only job.
|
||||
|
||||
Choose exactly one selector:
|
||||
|
||||
```bash
|
||||
codeman history read --file /path/to/rollout.jsonl
|
||||
codeman history read --session-id aaaaaaaa --sessions-root /path/to/sessions
|
||||
codeman history read --root-session-id aaaaaaaa-1111-4111-8111-111111111111 \
|
||||
--topic 'authentication' --after 2026-10-01 --before 2026-10-24 \
|
||||
--max-bytes 65536
|
||||
```
|
||||
|
||||
`--session-id` accepts a native conversation UUID or unique prefix of at least
|
||||
8 characters; identity comes from metadata, not filenames. `--root-session-id`
|
||||
requires a full native UUID and includes transitive descendants found in metadata
|
||||
(spawned sub-agents and forks). The default store is `$CODEX_HOME/sessions`, or
|
||||
`~/.codex/sessions` when unset; a store directory that is itself a symlink is
|
||||
followed, links inside it are skipped. `--file` selects one file and does not
|
||||
search for its ancestors or children.
|
||||
|
||||
Topic matching is a case-insensitive literal substring. Dates are inclusive
|
||||
`YYYY-MM-DD` bounds compared as UTC calendar dates. Each filter selects whole
|
||||
user/assistant turns; a turn is retained if it has a date match and a topic match,
|
||||
which may be different messages in that turn. Tool calls/results and injected
|
||||
context (rows Codex adds that start with `#` or `<`, such as AGENTS.md and
|
||||
`<environment_context>`) are omitted. Adjacent duplicate native representations
|
||||
are collapsed; repeated messages from the same representation are retained.
|
||||
|
||||
A forked or spawned thread's file starts with a copy of its parent's history. That
|
||||
copy is dropped (it is in the parent's own file), so each record belongs to the
|
||||
thread that wrote it. A later `session_meta` for any thread that is not a declared
|
||||
ancestor still refuses the file.
|
||||
|
||||
JSON is the default (`--json` or `--format json`); `--format text` prints one block
|
||||
per record. `--json` together with `--format text` is an error.
|
||||
Results identify source paths, native session/root IDs, timestamps, roles and phases,
|
||||
plus scan counters and `complete`. `phase` is Codex's own (`commentary` or
|
||||
`final_answer`) on assistant records and `null` on user records or when none was
|
||||
recorded. A root ID is `null` when ancestry cannot be proven.
|
||||
Report that uncertainty; never relabel the queried child as its own root.
|
||||
`complete: false` reports malformed or partial records (a running Codex may be
|
||||
mid-write), skipped links, unattributed files, compressed rollouts, unreadable
|
||||
directories, missing ancestors, or an inherited copy whose end could not be found
|
||||
or proven (including when the parent's file is present but cannot be read). It
|
||||
does not prove the store includes every conversation ever created, and a filtered
|
||||
result is only evidence for its selected scope.
|
||||
|
||||
Reads require regular UTF8 files and reject final-component symlinks, conflicting
|
||||
metadata, ambiguous IDs, cyclic ancestry and invalid selectors. A store file that
|
||||
cannot be attributed (invalid UTF8 in its head, no metadata in its first 128 KiB,
|
||||
changing while its head is read) is counted in `unattributedFiles` instead of
|
||||
failing the lookup. Codex's optional rollout compression rewrites cold rollouts to
|
||||
`*.jsonl.zst`; those are not read. Each is counted in `compressedFiles` (one whose
|
||||
plain `.jsonl` sibling exists is skipped, as Codex skips it), and a lookup that
|
||||
misses names them. A compressed file passed to `--file` is refused; read a
|
||||
decompressed copy instead (`zstd -dc <file> > copy.jsonl`). A selected rollout
|
||||
that a running Codex is appending to is read as a snapshot up to its last complete
|
||||
line; one that was replaced or truncated fails. Parent directories of an explicitly supplied path must be trusted; this is
|
||||
not a filesystem sandbox.
|
||||
|
||||
Budgets, each failing with an error rather than a partial answer. Every flag is
|
||||
validated before the first file is read, and only plain decimal integers are
|
||||
accepted:
|
||||
|
||||
| Flag | Bounds | Default | Maximum |
|
||||
| ---- | ------ | ------- | ------- |
|
||||
| `--metadata-bytes` | head reads attributing every rollout in the store | 1 GiB | 16 GiB |
|
||||
| `--max-entries` | directory entries visited | 65536 | 1048576 |
|
||||
| `--scan-bytes` | the selected transcripts, plus any parent file read to prove where a copy ends (each file charged once) | 16 MiB | 256 MiB |
|
||||
| `--max-bytes` | output, including the final newline | 8192 | 1048576 |
|
||||
|
||||
Each rollout's `session_meta` carries Codex's base instructions (about 20 KiB), so
|
||||
attributing a store costs about that much per rollout, charged to
|
||||
`--metadata-bytes`, never to `--scan-bytes`. Directory depth is capped at 8 (Codex
|
||||
writes `YYYY/MM/DD`). Output is never silently truncated or split into partial
|
||||
JSON or turns. Narrow the store/filters or explicitly raise the relevant budget.
|
||||
Missing evidence is an error, not an empty success.
|
||||
|
||||
This command does not inspect live panes, stage prompts, submit input or manage
|
||||
sessions. For live operations return to the managed-session guard in the skill.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Experimental bounded inspection and guarded submission
|
||||
|
||||
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
|
||||
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
|
||||
are labeled experimental and so are not part of the stable CLI/HTTP contract. Their commands, flags, paths,
|
||||
payloads and behavior may change or be removed in a minor or patch release.
|
||||
Pin a compatible Codeman version when automating them. Authentication and
|
||||
session ownership checks still apply.
|
||||
|
||||
They are also opt-in: until the user turns on **Experimental agent coordination**
|
||||
in App Settings (`agentCoordinationEnabled`, off by default), every one of them
|
||||
fails with `FORBIDDEN: ... (HTTP 403)` naming that setting. That is not something
|
||||
to retry or work around: tell the user, or use `send`/`read` instead.
|
||||
|
||||
Use these commands inside a Codeman session. They use its authenticated HTTP API.
|
||||
They supplement `send`, `wait` and `read` over the same HTTP transport; no direct
|
||||
tmux access is required.
|
||||
|
||||
## Inspect recent evidence
|
||||
|
||||
```bash
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --json
|
||||
codeman agent inspect "$SID" --max-bytes 8192 --since "$CURSOR" --json
|
||||
```
|
||||
|
||||
Read `source`, `identity`, `activity`, `evidence`, `truncated` and `cursor` before
|
||||
interpreting the result. `maxBytes` bounds UTF8 evidence bytes, excluding the JSON
|
||||
metadata/envelope. The default is 8192, maximum 65536.
|
||||
|
||||
- `codex-jsonl`: strictly attributed local Codex JSONL records. The first call
|
||||
selects recent complete records within a bounded tail window;
|
||||
`omittedBeforeBytes` labels older bytes deliberately omitted. A cursor continues
|
||||
after the last consumed complete record. Partial final records remain pending.
|
||||
- `pane-snapshot`: UTF8-safe tail of a visible frame or the session's buffered
|
||||
output. It has `incremental:false` and `cursor:null`. This is the fallback for
|
||||
other modes, remote/container sessions, ambiguous attribution, and exhausted
|
||||
attribution budgets. Repeating a snapshot can repeat evidence.
|
||||
|
||||
Attribution proves which file supplied the evidence, not which thread is now
|
||||
active in the TUI. A launch resume that conflicts with a newly owned rollout
|
||||
falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator
|
||||
thread may leave launch metadata unchanged. Check `identity.conversationId`;
|
||||
these reads do not provide authoritative current-thread tracking.
|
||||
|
||||
Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the
|
||||
requesting identity, session, pane lifecycle generation
|
||||
and attributed transcript, expire after 15 minutes, and disappear on server
|
||||
restart. Each caller and session keeps its newest 8 (so retrying the last call
|
||||
works), at most 1024 in all; an older cursor can be retired. A replaced/truncated/rewritten source, reassociated session, foreign cursor
|
||||
or unavailable native source returns `409 CONFLICT`. Inspect again without
|
||||
`--since` to establish a new tail baseline; this may omit older evidence.
|
||||
|
||||
`blockedReason` explains an unfinished or oversized record. Incremental reads
|
||||
never split or silently skip one. Increase the byte budget for
|
||||
`record-exceeds-output-budget`; when the record exceeds the maximum or scan cap,
|
||||
use `read --tail` for a labeled terminal view or start a fresh tail baseline.
|
||||
Inspect scans at most 256 KiB of record data plus bounded generation anchors.
|
||||
Attribution separately caps directory entries (8192), depth (8), negative-filter
|
||||
prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and
|
||||
activity are observations, not proof of task completion or authorization.
|
||||
|
||||
## Stage, inspect, then submit once
|
||||
|
||||
```bash
|
||||
printf '%s' 'Review src/ and report the failing tests' > /tmp/codeman-message.txt
|
||||
codeman agent stage "$SID" --message-file /tmp/codeman-message.txt --json
|
||||
# Review the returned stageId, sha256, expiresAt; inspect the target if needed.
|
||||
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json
|
||||
```
|
||||
|
||||
Stage types the file contents **without Enter**. The file must be regular, UTF8,
|
||||
one printable line, at most 8192 bytes, with no control characters or trailing
|
||||
whitespace/newline. It initially supports a local Codex composer with a recognized
|
||||
footer and an exact, unwrapped line/caret capture. Before typing, stage checks
|
||||
the actual captured width and composer indentation; a message that would reach
|
||||
the right edge or wrap is refused without writing anything. Unknown, working, modal,
|
||||
wrapped, remote/container and shell composers are refused. A customized footer or
|
||||
unsupported character-width rendering can also make verification unavailable.
|
||||
Self-targets are refused by the CLI.
|
||||
|
||||
Stage also refuses, before typing, a message that would change what Enter does:
|
||||
one starting with `/` (a Codex slash command; with its command list open, Enter
|
||||
would run the highlighted command instead) or `!` (a shell command), and one whose
|
||||
**last** word starts with `@` (file/mention search) or `$` (skills). Those popups
|
||||
follow the caret, which stage leaves at the end, so `look at @src/app.ts please`
|
||||
is fine. Send slash and shell commands with `codeman agent send`, and end a
|
||||
message with an ordinary word when it mentions a file. A frame showing a
|
||||
suggestion popup above the composer is refused at stage and submit too.
|
||||
|
||||
The receipt stores a hash and ephemeral identities, never prompt content. It
|
||||
expires after two minutes; capacity is 1024 live receipts. After typing, stage
|
||||
re-reads the pane (every 50 ms, up to one second, since Codex repaints a little
|
||||
after the keys) and issues a receipt only for a frame that shows exactly the
|
||||
message with the caret after it. Any refusal after the keys went out (typing
|
||||
failed or was ambiguous, the pane or input changed, or no frame verified in time)
|
||||
says the message may already be in the composer: no receipt was issued. Do not
|
||||
stage again on top of it; follow [Recover a stranded draft](#recover-a-stranded-draft) below first.
|
||||
|
||||
While a submit's Enter is in flight (milliseconds, plus the moment it takes to
|
||||
write input held behind it), TAGGED input to that session (`clientId` and `seq`,
|
||||
which `codeman agent send` and `interrupt` always carry) answers `409 CONFLICT`
|
||||
with nothing written; retry it. Untagged input, terminal keystrokes, `send-key`
|
||||
and Codeman's own managed writes are held instead and written in arrival order
|
||||
once the Enter is out.
|
||||
|
||||
Submit checks the caller, session, hash, expiry, pane lifecycle, input revision,
|
||||
geometry, exact composer and caret, then sends one conditional Enter at the session's input
|
||||
boundary. It first waits until 250 ms have passed since stage typed (Codex turns an
|
||||
Enter that follows typed text within 120 ms into a newline), so submitting right
|
||||
after stage is fine. Competing Codeman writes are held during dispatch and written
|
||||
after it, except tagged input, which is refused with `409`. Browser/API/key
|
||||
input and managed restarts invalidate receipts even when the visible text or pid
|
||||
is restored. Direct tmux edits are outside that input serialization; captures
|
||||
remain evidence at the time observed, rather than terminal isolation.
|
||||
|
||||
Every owned submit attempt consumes its receipt, including hash mismatch,
|
||||
verification failure and delivery failure. Concurrent/repeated submits cannot
|
||||
replay Enter, and there is no autonomous retry. A successful response means one
|
||||
Enter was delivered; use inspection or a suitable wait signal to observe native
|
||||
acceptance. A `409` or broken connection means inspect first. Do not automatically
|
||||
stage/submit again or send an unguarded Enter to recover.
|
||||
|
||||
## Recover a stranded draft
|
||||
|
||||
A consumed receipt cannot be reused, and a stage refused after typing issued
|
||||
none. First observe whether the prompt is still in the composer or was accepted: use transcript evidence/wait signals and a fresh terminal capture
|
||||
(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has
|
||||
accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing;
|
||||
do not clear or resend anything. A transcript prompt row alone is not proof
|
||||
that text remains unsent in the current composer.
|
||||
|
||||
Only recover an unambiguously unsent draft in an input-ready local Codex
|
||||
composer: verify the complete draft, recognized footer and caret at its end in
|
||||
the current frame, and ensure no other writer can edit this target during
|
||||
recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear
|
||||
it in the terminal instead of guessing keys. Never use `send` to overwrite a
|
||||
draft: that appends and submits both prompts together.
|
||||
|
||||
After those checks, the known Codex line-clear chord may be sent once through
|
||||
the existing raw input route, with **no Enter**. This conditional example uses
|
||||
the authenticated `CURL`/`API` values loaded by §0 and the exact target id:
|
||||
|
||||
```bash
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}'
|
||||
```
|
||||
|
||||
Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and
|
||||
verify the composer is empty and input-ready. Only then create a **fresh** stage
|
||||
receipt for the intended prompt and submit that receipt once. If the draft did
|
||||
not clear, the target began working, or verification remains uncertain, stop and
|
||||
ask the user; do not send an unguarded Enter or replay the old prompt.
|
||||
@@ -48,7 +48,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
||||
|
||||
| Job | Channel |
|
||||
| --- | --- |
|
||||
| spawn a worker, create its case | HTTP `quick-start` (the only path) |
|
||||
| spawn a worker, create its case | HTTP `quick-start` (the only path that creates a case); an existing directory is `POST sessions` + `interactive` (`codeman agent spawn --working-dir`) |
|
||||
| readiness, incl. the trust dialog | HTTP, Flow 1 (a message cannot answer a dialog) |
|
||||
| deliver a task to a READY claude worker | **messaging** (preferred) or HTTP input |
|
||||
| steer a BUSY claude worker mid-turn | **messaging** (read between the worker's tool calls; the HTTP path can only type into the composer, where text waits for the turn to end) |
|
||||
|
||||
@@ -31,7 +31,7 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo:
|
||||
|-------------------|------|------------------------------|
|
||||
| a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy |
|
||||
| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check |
|
||||
| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` (what `codeman agent spawn --working-dir /abs/path` sends) | same: **hooks installed at session create**, subject to the same setting. Check |
|
||||
|
||||
Read `.data.casePath` back from the `quick-start` response and check it is where you
|
||||
meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through
|
||||
@@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup
|
||||
that half is the CLI's behavior, not something this API guarantees.
|
||||
|
||||
- **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a
|
||||
typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it
|
||||
with `{"input":"\r"}` and let the worker read the junk line.
|
||||
typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified
|
||||
draft merely to clear it. For a refused guarded local Codex stage or submit, use the
|
||||
verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-a-stranded-draft);
|
||||
when the composer is unclear, ask the user to clear it in the terminal.
|
||||
- The interrupted turn already burned its tokens. Interrupting early saves the rest.
|
||||
- `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its
|
||||
allowlist is S-Enter / C-Enter only.
|
||||
@@ -811,3 +813,16 @@ Rules that matter:
|
||||
not parked, `send` it one line ("read your inbox: `codeman agent inbox`").
|
||||
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
|
||||
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
|
||||
|
||||
## Experimental bounded inspection and guarded submission
|
||||
|
||||
These three commands and their HTTP routes are labeled experimental, so they may
|
||||
change or be removed in minor or patch releases. They are also opt-in
|
||||
(`agentCoordinationEnabled`, off by default: a 403 naming it until the user turns
|
||||
it on). Use `codeman agent inspect` for bounded evidence and incremental local
|
||||
Codex cursors.
|
||||
Use `codeman agent stage --message-file` followed by one hash-confirmed
|
||||
`codeman agent submit --stage-id --confirm-sha` when the exact local Codex
|
||||
composer and caret can be verified. Other targets provide labeled snapshots or
|
||||
refuse guarded input. See [inspection and staging](inspection-and-staging.md)
|
||||
for budgets, receipt expiry, conflict handling and supported composers.
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses
|
||||
* newest-mtime, prompt timestamps or cwd alone as proof of session ownership.
|
||||
* The first call walks the store under explicit budgets; the attributed file is
|
||||
* then remembered per session and only re-verified (plus a scan of the date
|
||||
* directories not older than it) on later calls, since codex never prunes
|
||||
* `~/.codex/sessions`.
|
||||
*/
|
||||
import { open, opendir, realpath } from 'node:fs/promises';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
||||
const HEAD_BYTES = 131072;
|
||||
const PREFIX_SCAN_BYTES = 8 * 1024 * 1024;
|
||||
export interface CodexAttributionOptions {
|
||||
codexHome: string;
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
export interface CodexAttributionBudget {
|
||||
maxEntries: number;
|
||||
maxBytes: number;
|
||||
/** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */
|
||||
maxPrefixBytes?: number;
|
||||
}
|
||||
interface Metadata {
|
||||
id?: string;
|
||||
cwd?: string;
|
||||
originator?: string;
|
||||
}
|
||||
async function canonical(path: string): Promise<string | undefined> {
|
||||
if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined;
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
/** Match the CLI's relative CODEX_HOME interpretation without exposing env credentials. */
|
||||
export function resolveEffectiveCodexHome(
|
||||
sessionHome: string | undefined,
|
||||
workingDir: string,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env
|
||||
): string {
|
||||
const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex'));
|
||||
return path;
|
||||
}
|
||||
/** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */
|
||||
function strictMetadata(bytes: Buffer): Metadata | undefined {
|
||||
const rows = bytes.toString('utf8').split('\n');
|
||||
rows.pop();
|
||||
let found: Metadata | undefined;
|
||||
for (const line of rows) {
|
||||
try {
|
||||
const row = JSON.parse(line) as { type?: string; payload?: Metadata };
|
||||
if (row.type !== 'session_meta') continue;
|
||||
if (
|
||||
found ||
|
||||
typeof row.payload?.id !== 'string' ||
|
||||
typeof row.payload.cwd !== 'string' ||
|
||||
(row.payload.originator !== undefined && typeof row.payload.originator !== 'string')
|
||||
)
|
||||
return undefined;
|
||||
found = row.payload;
|
||||
} catch {
|
||||
/* Incomplete or malformed rows are not identity evidence. */
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
/**
|
||||
* The rollout a previous call attributed, per session. A later call re-verifies only
|
||||
* this file's head (same `dev:ino`, same strict metadata) and then scans just the
|
||||
* directories not older than its own for a second candidate (codex `/new`, or a new
|
||||
* owned rollout beside a configured resume), instead of re-reading the whole store,
|
||||
* which codex never prunes. A head that no longer verifies drops the entry and falls
|
||||
* back to the full walk; a failed incremental scan drops it and answers unattributed,
|
||||
* since the full walk reads a superset of that scan. Bounded: one entry per live
|
||||
* session is the expected size.
|
||||
*/
|
||||
interface AttributionMemo {
|
||||
id: string;
|
||||
path: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
meta: Metadata;
|
||||
/** Directory components of `path` below `sessions/`, used to prune older siblings. */
|
||||
dirParts: string[];
|
||||
}
|
||||
const attributionMemo = new LRUMap<string, AttributionMemo>({ maxSize: 1024 });
|
||||
/** Test-only: forget every remembered attribution. */
|
||||
export function __clearCodexAttributionMemo(): void {
|
||||
attributionMemo.clear();
|
||||
}
|
||||
/** Short first read: `originator` sits right after `id`, `timestamp` and `cwd`. */
|
||||
const PREFIX_FIRST_BYTES = 1024;
|
||||
/** Fallback read when the first window holds no complete `originator` field. */
|
||||
const PREFIX_MAX_BYTES = 4096;
|
||||
const ORIGINATOR_FIELD = /"originator"\s*:\s*"(?:[^"\\]|\\.)*"/;
|
||||
/**
|
||||
* Is a date-shaped directory name older than the remembered one at the same depth?
|
||||
* Only all-digit names compare (codex writes `sessions/YYYY/MM/DD/`); anything else
|
||||
* is never pruned, so an unexpected layout costs a wider scan, not a missed rollout.
|
||||
*/
|
||||
function olderDatePart(name: string, memo: string | undefined): boolean {
|
||||
if (memo === undefined || !/^\d+$/.test(name) || !/^\d+$/.test(memo)) return false;
|
||||
return Number(name) < Number(memo);
|
||||
}
|
||||
/**
|
||||
* A configured resume and a newly owned rollout conflict rather than pinning an
|
||||
* old launch forever after /new. TUI resumes of foreign-originator threads cannot
|
||||
* be authoritatively tracked from this metadata; this returns attributed evidence.
|
||||
*/
|
||||
export async function findExactCodexConversation(
|
||||
options: CodexAttributionOptions,
|
||||
budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 }
|
||||
): Promise<{ id: string; path: string } | undefined> {
|
||||
const workspace = await canonical(options.workingDir);
|
||||
const home = await canonical(options.codexHome);
|
||||
if (!workspace || !home) return undefined;
|
||||
const sessions = join(home, 'sessions');
|
||||
if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined;
|
||||
const key = [home, options.sessionId, options.resumeSessionId ?? '', workspace].join('\0');
|
||||
const remembered = attributionMemo.get(key);
|
||||
if (remembered) {
|
||||
const verified = await verifyRemembered(remembered, workspace, budget);
|
||||
if (verified) {
|
||||
const others = await scanCandidates(sessions, options, budget, remembered.dirParts);
|
||||
if (others && others.length === 1 && others[0].path === remembered.path) {
|
||||
return { id: remembered.id, path: remembered.path };
|
||||
}
|
||||
}
|
||||
attributionMemo.delete(key);
|
||||
// A file that no longer verifies (replaced, rewritten, gone) gets one full walk
|
||||
// now. A verified file whose incremental scan failed does not: that scan reads a
|
||||
// subset of what a full walk reads under the same budget, so a second candidate,
|
||||
// an exhausted budget or an unreadable entry it hit is one the full walk would hit
|
||||
// too, and walking the whole never-pruned store could only end the same way.
|
||||
if (verified) return undefined;
|
||||
}
|
||||
const candidates = await scanCandidates(sessions, options, budget);
|
||||
if (!candidates || candidates.length !== 1) return undefined;
|
||||
const candidate = candidates[0];
|
||||
if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined;
|
||||
const handle = await open(candidate.path, 'r').catch(() => undefined);
|
||||
if (!handle) return undefined;
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
const bytes = Buffer.alloc(HEAD_BYTES);
|
||||
const read = await handle.read(bytes, 0, bytes.length, 0);
|
||||
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
||||
if (
|
||||
!meta ||
|
||||
meta.id !== candidate.id ||
|
||||
meta.cwd !== candidate.meta.cwd ||
|
||||
meta.originator !== candidate.meta.originator
|
||||
)
|
||||
return undefined;
|
||||
attributionMemo.set(key, {
|
||||
id: candidate.id,
|
||||
path: candidate.path,
|
||||
dev: info.dev,
|
||||
ino: info.ino,
|
||||
meta,
|
||||
dirParts: relative(sessions, dirname(candidate.path)).split(sep).filter(Boolean),
|
||||
});
|
||||
return { id: candidate.id, path: candidate.path };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
/** Same file (`dev:ino`), same strict metadata, same canonical workspace: nothing else. */
|
||||
async function verifyRemembered(
|
||||
memo: AttributionMemo,
|
||||
workspace: string,
|
||||
budget: CodexAttributionBudget
|
||||
): Promise<boolean> {
|
||||
if (HEAD_BYTES > budget.maxBytes) return false;
|
||||
if (!memo.meta.cwd || (await canonical(memo.meta.cwd)) !== workspace) return false;
|
||||
const handle = await open(memo.path, 'r').catch(() => undefined);
|
||||
if (!handle) return false;
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile() || info.dev !== memo.dev || info.ino !== memo.ino) return false;
|
||||
const bytes = Buffer.alloc(HEAD_BYTES);
|
||||
const read = await handle.read(bytes, 0, bytes.length, 0);
|
||||
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
||||
return !!meta && meta.id === memo.id && meta.cwd === memo.meta.cwd && meta.originator === memo.meta.originator;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Every rollout under `sessions` whose strict metadata names this session (or the
|
||||
* configured resume id), or undefined when the walk hit a bound or an unreadable
|
||||
* entry (an incomplete inventory proves nothing). With `notOlderThan`, date-shaped
|
||||
* directories older than those components are skipped.
|
||||
*/
|
||||
async function scanCandidates(
|
||||
sessions: string,
|
||||
options: CodexAttributionOptions,
|
||||
budget: CodexAttributionBudget,
|
||||
notOlderThan?: string[]
|
||||
): Promise<Array<{ id: string; path: string; meta: Metadata }> | undefined> {
|
||||
// `onBoundary`: every ancestor so far equals the remembered path, so pruning applies.
|
||||
const directories = [{ path: sessions, depth: 0, onBoundary: notOlderThan !== undefined }];
|
||||
const candidates: Array<{ id: string; path: string; meta: Metadata }> = [];
|
||||
const originator = `codeman_${options.sessionId}`;
|
||||
const originatorJson = JSON.stringify(originator);
|
||||
const explicit = options.resumeSessionId;
|
||||
let entries = 0;
|
||||
let prefixes = 0;
|
||||
const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES);
|
||||
let scanned = HEAD_BYTES; // Reserve an independent final verification.
|
||||
while (directories.length) {
|
||||
const dir = directories.pop()!;
|
||||
const handle = await opendir(dir.path).catch(() => undefined);
|
||||
if (!handle) return undefined;
|
||||
for await (const entry of handle) {
|
||||
if (++entries > budget.maxEntries) return undefined;
|
||||
const path = join(dir.path, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (dir.depth >= 8) return undefined;
|
||||
const part = notOlderThan?.[dir.depth];
|
||||
if (dir.onBoundary && olderDatePart(entry.name, part)) continue;
|
||||
directories.push({ path, depth: dir.depth + 1, onBoundary: dir.onBoundary && entry.name === part });
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
const id = entry.name.match(UUID_AT_END)?.[1];
|
||||
if (!id) continue; // no rollout id in the name: never a candidate, read nothing
|
||||
const file = await open(path, 'r').catch(() => undefined);
|
||||
if (!file) return undefined;
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile()) return undefined;
|
||||
if (id !== explicit) {
|
||||
// Cheap negative filter only; a match still needs full parsed metadata.
|
||||
// A short first window decides when it already holds the whole
|
||||
// `originator` field; only otherwise is the wider window read.
|
||||
let window = Math.min(info.size, PREFIX_FIRST_BYTES);
|
||||
let prefix = '';
|
||||
for (;;) {
|
||||
if (prefixes + window > prefixLimit) return undefined;
|
||||
prefixes += window;
|
||||
const buffer = Buffer.alloc(window);
|
||||
const first = await file.read(buffer, 0, window, 0);
|
||||
prefix = buffer.subarray(0, first.bytesRead).toString('utf8');
|
||||
if (prefix.includes(originatorJson) || ORIGINATOR_FIELD.test(prefix)) break;
|
||||
const wider = Math.min(info.size, PREFIX_MAX_BYTES);
|
||||
if (wider <= window) break;
|
||||
window = wider;
|
||||
}
|
||||
if (!prefix.includes(originatorJson)) continue;
|
||||
}
|
||||
const size = Math.min(info.size, HEAD_BYTES);
|
||||
if (scanned + size > budget.maxBytes) return undefined;
|
||||
scanned += size;
|
||||
const bytes = Buffer.alloc(size);
|
||||
const read = await file.read(bytes, 0, size, 0);
|
||||
const meta = strictMetadata(bytes.subarray(0, read.bytesRead));
|
||||
if (!meta || meta.id !== id) continue;
|
||||
if (id === explicit || meta.originator === originator) candidates.push({ id, path, meta });
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @fileoverview Bounded append-only JSONL evidence reads for agent inspection.
|
||||
* Opaque, expiring server-side cursors contain no public filesystem paths. A cursor
|
||||
* binds the caller, pane generation, attributed conversation and opened file identity.
|
||||
* Complete records only; an oversized row stalls explicitly rather than losing evidence.
|
||||
*/
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
|
||||
export const INSPECT_MAX_BYTES = 65536;
|
||||
export const INSPECT_SCAN_BYTES = 262144;
|
||||
export interface InspectionScope {
|
||||
owner: string;
|
||||
sessionId: string;
|
||||
generation: string;
|
||||
conversationId: string;
|
||||
}
|
||||
interface CursorState {
|
||||
scope: string;
|
||||
/** Owner and session only: the unit the per-scope cap counts. */
|
||||
holder: string;
|
||||
path: string;
|
||||
identity: string;
|
||||
offset: number;
|
||||
observedSize: number;
|
||||
modifiedAt: number;
|
||||
changedAt: number;
|
||||
anchor: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
export class InspectionConflict extends Error {}
|
||||
|
||||
/** UTF8-safe suffix, also used for explicitly non-incremental pane evidence. */
|
||||
export function boundedUtf8Tail(text: string, maxBytes: number): string {
|
||||
// A UTF8 suffix fits within this many UTF16 units. Include a pair of extra
|
||||
// units so slicing cannot introduce a surrogate replacement inside the result.
|
||||
const bytes = Buffer.from(text.slice(-maxBytes - 2));
|
||||
let start = Math.max(0, bytes.length - maxBytes);
|
||||
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
||||
return bytes.subarray(start).toString('utf8');
|
||||
}
|
||||
async function anchor(handle: FileHandle, offset: number): Promise<string> {
|
||||
const count = Math.min(offset, 4096);
|
||||
const head = Buffer.alloc(count);
|
||||
const tail = Buffer.alloc(count);
|
||||
await handle.read(head, 0, count, 0);
|
||||
await handle.read(tail, 0, count, Math.max(0, offset - count));
|
||||
return createHash('sha256').update(head).update(tail).digest('hex');
|
||||
}
|
||||
|
||||
/** Cursors kept per owner and session; older ones are retired first. */
|
||||
export const INSPECT_CURSORS_PER_SCOPE = 8;
|
||||
|
||||
export class InspectionReader {
|
||||
private readonly cursors = new Map<string, CursorState>();
|
||||
constructor(
|
||||
private readonly now = Date.now,
|
||||
private readonly capacity = 1024,
|
||||
private readonly ttlMs = 900000,
|
||||
private readonly perScope = INSPECT_CURSORS_PER_SCOPE
|
||||
) {}
|
||||
|
||||
async read(path: string, scope: InspectionScope, maxBytes: number, since?: string) {
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > INSPECT_MAX_BYTES)
|
||||
throw new Error('Invalid byte budget');
|
||||
const scopeKey = JSON.stringify(scope);
|
||||
const previous = since ? this.cursors.get(since) : undefined;
|
||||
if (
|
||||
since &&
|
||||
(!previous || previous.expiresAt <= this.now() || previous.scope !== scopeKey || previous.path !== path)
|
||||
) {
|
||||
throw new InspectionConflict('Invalid or expired inspection cursor; inspect without since to start again');
|
||||
}
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (!before.isFile()) throw new InspectionConflict('Inspection source is not a regular file');
|
||||
const identity = `${before.dev}:${before.ino}:${before.birthtimeMs}`;
|
||||
let offset = previous?.offset ?? Math.max(0, before.size - INSPECT_SCAN_BYTES + 1);
|
||||
if (
|
||||
previous &&
|
||||
(identity !== previous.identity ||
|
||||
before.size < previous.observedSize ||
|
||||
(before.size === previous.observedSize &&
|
||||
(before.mtimeMs !== previous.modifiedAt || before.ctimeMs !== previous.changedAt)) ||
|
||||
(await anchor(handle, offset)) !== previous.anchor)
|
||||
) {
|
||||
throw new InspectionConflict('Inspection cursor source changed; inspect without since to start again');
|
||||
}
|
||||
let startsAtBoundary = offset === 0;
|
||||
if (!previous && offset > 0) {
|
||||
const preceding = Buffer.alloc(1);
|
||||
await handle.read(preceding, 0, 1, offset - 1);
|
||||
startsAtBoundary = preceding[0] === 10;
|
||||
}
|
||||
const scan = Buffer.alloc(
|
||||
Math.min(INSPECT_SCAN_BYTES - (!previous && offset > 0 ? 1 : 0), Math.max(0, before.size - offset))
|
||||
);
|
||||
const { bytesRead } = await handle.read(scan, 0, scan.length, offset);
|
||||
let selectedStart = 0;
|
||||
let consumed = 0;
|
||||
let blockedReason: string | undefined;
|
||||
let omittedBeforeBytes = 0;
|
||||
if (!previous) {
|
||||
const leading = startsAtBoundary ? 0 : scan.indexOf(10) + 1;
|
||||
const completeEnd = scan.lastIndexOf(10, bytesRead - 1) + 1;
|
||||
if (!startsAtBoundary && leading === 0) {
|
||||
// The tail lies wholly inside one unfinished oversized record. Its
|
||||
// beginning is not known: retain the known file boundary rather than
|
||||
// manufacture a cursor pointing into a JSON/UTF8 fragment.
|
||||
offset = 0;
|
||||
blockedReason = 'record-exceeds-scan-limit';
|
||||
} else {
|
||||
selectedStart = completeEnd;
|
||||
consumed = completeEnd;
|
||||
while (selectedStart > leading) {
|
||||
const recordStart = Math.max(leading, scan.lastIndexOf(10, selectedStart - 2) + 1);
|
||||
if (completeEnd - recordStart > maxBytes) {
|
||||
if (selectedStart === completeEnd) {
|
||||
selectedStart = recordStart;
|
||||
consumed = recordStart;
|
||||
blockedReason = 'record-exceeds-output-budget';
|
||||
}
|
||||
break;
|
||||
}
|
||||
selectedStart = recordStart;
|
||||
}
|
||||
if (completeEnd < bytesRead && !blockedReason) blockedReason = 'partial-record';
|
||||
// If the only known boundary is the end of a skipped leading row,
|
||||
// the remainder is a partial record and stays pending there.
|
||||
if (completeEnd < leading) selectedStart = consumed = leading;
|
||||
omittedBeforeBytes = offset + selectedStart;
|
||||
}
|
||||
} else {
|
||||
while (consumed < bytesRead) {
|
||||
const end = scan.indexOf(10, consumed);
|
||||
if (end < 0) {
|
||||
blockedReason = bytesRead === INSPECT_SCAN_BYTES ? 'record-exceeds-scan-limit' : 'partial-record';
|
||||
break;
|
||||
}
|
||||
if (end + 1 > maxBytes) {
|
||||
if (consumed === 0) blockedReason = 'record-exceeds-output-budget';
|
||||
break;
|
||||
}
|
||||
consumed = end + 1;
|
||||
}
|
||||
}
|
||||
const evidenceBytes = scan.subarray(selectedStart, consumed);
|
||||
if (!isUtf8(evidenceBytes)) throw new InspectionConflict('Inspection source contains invalid UTF8');
|
||||
for (const row of evidenceBytes.toString('utf8').split('\n').slice(0, -1)) {
|
||||
try {
|
||||
JSON.parse(row);
|
||||
} catch {
|
||||
throw new InspectionConflict('Inspection source contains invalid JSONL');
|
||||
}
|
||||
}
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
after.size < before.size ||
|
||||
after.ino !== before.ino ||
|
||||
(after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs))
|
||||
)
|
||||
throw new InspectionConflict('Inspection source changed during read');
|
||||
const nextAnchor = await anchor(handle, offset + consumed);
|
||||
const cursor = randomUUID();
|
||||
// No await between capacity enforcement and insertion: concurrent reads
|
||||
// must not all reserve the same remaining slot while hashing anchors.
|
||||
// Each owner and session keeps only its newest cursors, so one agent polling in
|
||||
// a loop retires its own oldest ones instead of pushing every other caller's out
|
||||
// of the shared map. The cursor just used stays among the newest, so a retry of
|
||||
// the last call still works. The global capacity is only a backstop.
|
||||
const holder = JSON.stringify([scope.owner, scope.sessionId]);
|
||||
const mine: string[] = [];
|
||||
for (const [key, value] of this.cursors) {
|
||||
if (value.expiresAt <= this.now()) this.cursors.delete(key);
|
||||
else if (value.holder === holder) mine.push(key);
|
||||
}
|
||||
while (mine.length >= this.perScope) this.cursors.delete(mine.shift()!);
|
||||
while (this.cursors.size >= this.capacity) this.cursors.delete(this.cursors.keys().next().value!);
|
||||
this.cursors.set(cursor, {
|
||||
scope: scopeKey,
|
||||
holder,
|
||||
path,
|
||||
identity,
|
||||
offset: offset + consumed,
|
||||
observedSize: before.size,
|
||||
modifiedAt: before.mtimeMs,
|
||||
changedAt: before.ctimeMs,
|
||||
anchor: nextAnchor,
|
||||
expiresAt: this.now() + this.ttlMs,
|
||||
});
|
||||
return {
|
||||
evidence: evidenceBytes.toString('utf8'),
|
||||
cursor,
|
||||
omittedBeforeBytes,
|
||||
truncated: omittedBeforeBytes > 0 || offset + consumed < before.size,
|
||||
blockedReason,
|
||||
scannedBytes: bytesRead,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
clear(): void {
|
||||
this.cursors.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @fileoverview Short-lived, bounded guarded prompt receipts. No prompt content is
|
||||
* retained: only caller/session identity, lifecycle/input revisions and SHA-256.
|
||||
* A receipt is consumed synchronously before verification/delivery, including failures.
|
||||
*/
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
import { visibleWidth } from './tui/tui-ansi.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
|
||||
/**
|
||||
* The screen chrome a guarded composer read relies on, all from the CLI registry
|
||||
* (`capabilities.workDetect`): the composer glyph, the pattern every row below an
|
||||
* idle composer must match, and what opens a popup or turns Enter into a command.
|
||||
* Nothing here names a CLI; a CLI takes part in guarded input by declaring
|
||||
* `composerFooterLine`.
|
||||
*/
|
||||
export interface GuardedComposerChrome {
|
||||
promptGlyph: string;
|
||||
footerLine: RegExp;
|
||||
/** A row of a suggestion popup drawn directly above the composer (`composerPopupLine`). */
|
||||
popupLine?: RegExp;
|
||||
/** First characters of a draft that make Enter run a command (`composerCommandPrefixes`). */
|
||||
commandPrefixes?: string;
|
||||
/** First characters of the word at the caret that open a completion popup (`composerCompletionSigils`). */
|
||||
completionSigils?: string;
|
||||
}
|
||||
/** The chrome a registry entry declares, or null (no guarded input for that CLI). */
|
||||
export function guardedComposerChrome(entry: CliEntry | undefined): GuardedComposerChrome | null {
|
||||
const detect = entry?.capabilities.workDetect;
|
||||
if (!detect?.promptGlyph || !detect.composerFooterLine) return null;
|
||||
// Config-supplied (~/.codeman/clis.json): compiled through the ReDoS guard, null on refusal.
|
||||
const footerLine = compileVersionRegex(detect.composerFooterLine);
|
||||
if (!footerLine) return null;
|
||||
// A declared popup pattern the guard refuses is no chrome at all: dropping it would
|
||||
// silently read a popup frame as an idle composer.
|
||||
const popupLine = detect.composerPopupLine ? compileVersionRegex(detect.composerPopupLine) : undefined;
|
||||
if (popupLine === null) return null;
|
||||
return {
|
||||
promptGlyph: detect.promptGlyph,
|
||||
footerLine,
|
||||
...(popupLine ? { popupLine } : {}),
|
||||
...(detect.composerCommandPrefixes ? { commandPrefixes: detect.composerCommandPrefixes } : {}),
|
||||
...(detect.composerCompletionSigils ? { completionSigils: detect.composerCompletionSigils } : {}),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Why a message cannot be staged into this composer, or null. Typing it would change
|
||||
* what Enter does rather than what it sends: a draft starting with a command prefix
|
||||
* (codex: `/` for a slash command, `!` for a shell command) is run, not sent as a
|
||||
* prompt, and a last word starting with a completion sigil (codex: `@` file and
|
||||
* mention search, `$` skills) opens a popup at the caret whose highlighted entry Enter
|
||||
* would accept. Checked before anything is typed. Only the LAST word matters for the
|
||||
* sigils because the caret is at the end of the message and the popup follows the
|
||||
* caret; a sigil word earlier in the message opens nothing.
|
||||
*/
|
||||
export function composerTriggerRefusal(message: string, chrome: GuardedComposerChrome): string | null {
|
||||
const first = message.trimStart().charAt(0);
|
||||
if (first && chrome.commandPrefixes?.includes(first))
|
||||
return `Stage refuses a message starting with "${first}": the CLI runs it as a command instead of sending it as a prompt. Send commands with \`codeman agent send\` (POST /api/v1/sessions/:id/input) instead`;
|
||||
const last = message.split(/\s/).at(-1)?.charAt(0);
|
||||
if (last && chrome.completionSigils?.includes(last))
|
||||
return `Stage refuses a message whose last word starts with "${last}": it opens a completion popup at the caret and Enter would accept the highlighted entry instead of sending the message. End the message with another word, or use \`codeman agent send\``;
|
||||
return null;
|
||||
}
|
||||
/** How many rows above the composer a popup check reads: the popup cap (8) plus header and hint rows. */
|
||||
const POPUP_WINDOW_ROWS = 12;
|
||||
/** Blank rows a popup can leave between itself and the composer row (the composer's own top padding, plus a popup's). */
|
||||
const POPUP_GAP_ROWS = 3;
|
||||
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const composerRow = (chrome: GuardedComposerChrome) =>
|
||||
new RegExp(String.raw`^\s*${escapeRegex(chrome.promptGlyph)}(?:\s|$)`);
|
||||
export const STAGE_MAX_BYTES = 8192;
|
||||
export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex');
|
||||
export interface GuardedInputSnapshot {
|
||||
generation: string;
|
||||
revision: number;
|
||||
composerSha: string;
|
||||
/** Actual captured pane width minus the rendered composer prefix (including indentation). */
|
||||
availableColumns: number;
|
||||
}
|
||||
export interface GuardedInputTarget {
|
||||
id: string;
|
||||
/** The session's composer chrome, read before typing for what a message would trigger. */
|
||||
chrome(): GuardedComposerChrome | null;
|
||||
snapshot(): GuardedInputSnapshot | null;
|
||||
write(text: string): Promise<boolean>;
|
||||
submit(expected: GuardedInputSnapshot): Promise<boolean>;
|
||||
}
|
||||
interface Receipt extends GuardedInputSnapshot {
|
||||
owner: string;
|
||||
sessionId: string;
|
||||
expiresAt: number;
|
||||
sha256: string;
|
||||
/** When the staged keys went out, for `SUBMIT_QUIET_MS`. */
|
||||
typedAt: number;
|
||||
}
|
||||
export class GuardedInputConflict extends Error {}
|
||||
|
||||
/**
|
||||
* How long stage keeps reading the pane after typing for a frame that shows the
|
||||
* message, and how often. A capture taken right after the keys land is often a
|
||||
* frame from before them: codex repaints its composer 30 to 60 ms after input
|
||||
* arrives (measured on a live 0.162.0 pane), so the first read can show the old,
|
||||
* empty composer, or nothing provable at all. A second of 50 ms reads covers that
|
||||
* many times over and still answers the caller quickly.
|
||||
*/
|
||||
export const STAGE_SETTLE_TIMEOUT_MS = 1000;
|
||||
export const STAGE_SETTLE_INTERVAL_MS = 50;
|
||||
/**
|
||||
* The least time between the staged keys and the conditional Enter. Stage types its
|
||||
* text as plain keys (`send-keys -l`), which codex's paste-burst detector reads as a
|
||||
* paste, and for 120 ms after such a burst codex turns Enter into a NEWLINE rather
|
||||
* than a submit (`PASTE_ENTER_SUPPRESS_WINDOW`, codex-rs/tui/src/bottom_pane/
|
||||
* paste_burst.rs at rust-v0.162.0). A caller that submits the moment stage answers
|
||||
* would land inside it and be told one Enter was delivered while codex only broke
|
||||
* the line. Submit waits out the rest of this window first; a keystroke in that
|
||||
* time still invalidates the receipt.
|
||||
*/
|
||||
export const SUBMIT_QUIET_MS = 250;
|
||||
/** Settle-window overrides for tests: a fake clock comes from the store's `now`. */
|
||||
export interface StageSettleOptions {
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Every refusal after the keys went out says so: stage cannot tell whether the text
|
||||
* landed, so the caller must look before staging again (the recovery steps clear a
|
||||
* stranded draft with Ctrl+U).
|
||||
*/
|
||||
export const MAY_BE_TYPED =
|
||||
'no receipt was issued. The message may already be in the composer: inspect the pane and follow the stranded-draft recovery steps (Ctrl+U) before staging again';
|
||||
|
||||
/**
|
||||
* Conservatively read a composer: exactly one visible, unwrapped row and a known
|
||||
* footer, both as the registry describes them. Never remove internal spaces, infer
|
||||
* an unknown shell prompt, or interpret dialog options as authority. An unsupported
|
||||
* frame yields no proof.
|
||||
*/
|
||||
export function readGuardedComposer(screen: string, chrome: GuardedComposerChrome): string | null {
|
||||
const isComposer = composerRow(chrome);
|
||||
const lines = stripAnsi(screen)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
const rows = lines.map((line, index) => ({ line, index })).filter((row) => isComposer.test(row.line));
|
||||
const last = rows.at(-1);
|
||||
if (!last) return null;
|
||||
const below = lines.slice(last.index + 1).filter((line) => line.trim());
|
||||
if (below.length === 0 || below.length > 3 || below.some((line) => !chrome.footerLine.test(line))) return null;
|
||||
if (chrome.popupLine && popupAbove(lines, last.index, chrome.popupLine)) return null;
|
||||
// Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded.
|
||||
const raw = screen.split('\n')[last.index];
|
||||
let dim = false;
|
||||
let visible = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
for (const token of raw.split(/(\x1b\[[0-9;]*m)/)) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^\x1b\[/.test(token)) {
|
||||
const codes = token.slice(2, -1).split(';').map(Number);
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
// Extended color payloads may contain 0, 2 or 22; those are not SGR modes.
|
||||
if (codes[i] === 38 || codes[i] === 48 || codes[i] === 58) {
|
||||
i += codes[i + 1] === 2 ? 4 : codes[i + 1] === 5 ? 2 : 0;
|
||||
} else if (codes[i] === 0 || codes[i] === 22) dim = false;
|
||||
else if (codes[i] === 2) dim = true;
|
||||
}
|
||||
} else if (!dim) visible += token;
|
||||
}
|
||||
// The glyph and the one space after it are the prefix, not input.
|
||||
return stripAnsi(visible)
|
||||
.trimStart()
|
||||
.slice(chrome.promptGlyph.length + 1)
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a suggestion popup sit directly above the composer row? A popup draws over
|
||||
* the rows above the composer and leaves the footer below it alone, so the footer
|
||||
* check alone reads a popup frame as an idle composer. Read the contiguous block of
|
||||
* rows just above (past at most `POPUP_GAP_ROWS` blank rows), never the whole pane:
|
||||
* the transcript further up is the agent's own and may print anything. A transcript
|
||||
* row that happens to match costs a refusal, never a receipt.
|
||||
*/
|
||||
function popupAbove(lines: string[], composerIndex: number, popupLine: RegExp): boolean {
|
||||
let index = composerIndex - 1;
|
||||
for (let gap = 0; gap < POPUP_GAP_ROWS && index >= 0 && !lines[index].trim(); gap++) index--;
|
||||
for (let seen = 0; seen < POPUP_WINDOW_ROWS && index >= 0 && lines[index].trim(); seen++, index--) {
|
||||
if (popupLine.test(lines[index])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Require the capture's caret at the exact end of the unwrapped composer. */
|
||||
export function readGuardedSnapshot(snapshot: string, chrome: GuardedComposerChrome): string | null {
|
||||
return readGuardedSnapshotDetails(snapshot, chrome)?.composer ?? null;
|
||||
}
|
||||
/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */
|
||||
export function readGuardedSnapshotDetails(
|
||||
snapshot: string,
|
||||
chrome: GuardedComposerChrome
|
||||
): { composer: string; prefixWidth: number } | null {
|
||||
// formatPaneSnapshot emits one absolute row address per line, then the caret.
|
||||
// Plain captures lack caret evidence and cannot prove invisible trailing edits.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const positions = [...snapshot.matchAll(/\x1b\[(\d+);(\d+)H/g)];
|
||||
if (positions.length < 2) return null;
|
||||
const caret = positions.at(-1)!;
|
||||
if (caret.index! + caret[0].length !== snapshot.length) return null;
|
||||
const rows: string[] = [];
|
||||
for (let i = 0; i < positions.length - 1; i++) {
|
||||
const position = positions[i];
|
||||
if (Number(position[1]) !== i + 1 || position[2] !== '1') return null;
|
||||
rows.push(snapshot.slice(position.index! + position[0].length, positions[i + 1].index));
|
||||
}
|
||||
const composer = readGuardedComposer(rows.join('\n'), chrome);
|
||||
if (composer === null) return null;
|
||||
const isComposer = composerRow(chrome);
|
||||
let row = -1;
|
||||
for (let i = rows.length - 1; i >= 0; i--)
|
||||
if (isComposer.test(stripAnsi(rows[i]))) {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
const prefix = stripAnsi(rows[row]).match(new RegExp(String.raw`^(\s*${escapeRegex(chrome.promptGlyph)}[ \u00a0])`));
|
||||
if (!prefix || Number(caret[1]) !== row + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1)
|
||||
return null;
|
||||
return { composer, prefixWidth: visibleWidth(prefix[1]) };
|
||||
}
|
||||
|
||||
export class GuardedStageStore {
|
||||
private readonly receipts = new Map<string, Receipt>();
|
||||
private readonly busy = new Set<string>();
|
||||
private readonly settleTimeoutMs: number;
|
||||
private readonly settleIntervalMs: number;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
constructor(
|
||||
private readonly now = Date.now,
|
||||
private readonly capacity = 1024,
|
||||
private readonly ttlMs = 120000,
|
||||
settle: StageSettleOptions = {}
|
||||
) {
|
||||
this.settleTimeoutMs = settle.timeoutMs ?? STAGE_SETTLE_TIMEOUT_MS;
|
||||
this.settleIntervalMs = settle.intervalMs ?? STAGE_SETTLE_INTERVAL_MS;
|
||||
this.sleep = settle.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
}
|
||||
async stage(target: GuardedInputTarget, owner: string, message: string) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const hasControls = /[\x00-\x1f\x7f-\x9f]/.test(message);
|
||||
if (
|
||||
!message ||
|
||||
message !== message.trimEnd() ||
|
||||
Buffer.byteLength(message) > STAGE_MAX_BYTES ||
|
||||
Buffer.from(message).toString('utf8') !== message ||
|
||||
hasControls
|
||||
)
|
||||
throw new GuardedInputConflict(
|
||||
'Stage requires printable single-line input within 8192 UTF8 bytes, without trailing whitespace'
|
||||
);
|
||||
const chrome = target.chrome();
|
||||
const refusal = chrome && composerTriggerRefusal(message, chrome);
|
||||
if (refusal) throw new GuardedInputConflict(refusal);
|
||||
const key = target.id;
|
||||
if (this.busy.has(key)) throw new GuardedInputConflict('Another guarded input operation is in progress');
|
||||
this.busy.add(key);
|
||||
try {
|
||||
const before = target.snapshot();
|
||||
if (!before || before.composerSha !== promptSha256(''))
|
||||
throw new GuardedInputConflict('An empty, verifiable composer is required');
|
||||
if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns)
|
||||
throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane');
|
||||
for (const [id, receipt] of this.receipts)
|
||||
if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id);
|
||||
// Refuse capacity before typing. Do not evict a caller's still-live receipt.
|
||||
// Include in-flight stages (this operation already owns one busy slot),
|
||||
// so different sessions cannot type concurrently into the same last slot.
|
||||
if (this.receipts.size + this.busy.size > this.capacity)
|
||||
throw new GuardedInputConflict('Guarded stage receipt capacity reached');
|
||||
if (!(await target.write(message)))
|
||||
throw new GuardedInputConflict(`Staging delivery failed or ambiguous; ${MAY_BE_TYPED}`);
|
||||
const typedAt = this.now();
|
||||
const after = await this.settle(target, before, promptSha256(message));
|
||||
const stageId = randomUUID();
|
||||
const expiresAt = this.now() + this.ttlMs;
|
||||
const sha256 = promptSha256(message);
|
||||
this.receipts.set(stageId, { ...after, owner, sessionId: target.id, expiresAt, sha256, typedAt });
|
||||
return { stageId, sha256, expiresAt, sessionId: target.id, submitted: false as const };
|
||||
} finally {
|
||||
this.busy.delete(key);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The first frame after the write that shows exactly this message, with the input
|
||||
* revision the write itself moved and nothing else changed. A null or stale frame
|
||||
* (the old composer, a half-painted one) is read again until the settle window
|
||||
* ends; a frame showing another writer's keystroke, a new pane lifecycle or a new
|
||||
* geometry is refused at once, since waiting cannot undo that.
|
||||
*/
|
||||
private async settle(
|
||||
target: GuardedInputTarget,
|
||||
before: GuardedInputSnapshot,
|
||||
expectedSha: string
|
||||
): Promise<GuardedInputSnapshot> {
|
||||
const deadline = this.now() + this.settleTimeoutMs;
|
||||
for (;;) {
|
||||
const frame = target.snapshot();
|
||||
if (
|
||||
frame &&
|
||||
(frame.generation !== before.generation ||
|
||||
frame.revision !== before.revision + 1 ||
|
||||
frame.availableColumns !== before.availableColumns)
|
||||
)
|
||||
throw new GuardedInputConflict(`Input or pane changed while staging; ${MAY_BE_TYPED}`);
|
||||
if (frame?.composerSha === expectedSha) return frame;
|
||||
if (this.now() >= deadline)
|
||||
throw new GuardedInputConflict(
|
||||
`The composer did not show the staged message within ${this.settleTimeoutMs} ms; ${MAY_BE_TYPED}`
|
||||
);
|
||||
await this.sleep(this.settleIntervalMs);
|
||||
}
|
||||
}
|
||||
async submit(target: GuardedInputTarget, owner: string, stageId: string, confirmSha: string) {
|
||||
const receipt = this.receipts.get(stageId);
|
||||
if (!receipt || receipt.owner !== owner || receipt.sessionId !== target.id)
|
||||
throw new GuardedInputConflict('Unknown, used, or foreign stage receipt');
|
||||
// One shot, even a wrong hash or unprovable composer. No retry of ambiguous Enter.
|
||||
this.receipts.delete(stageId);
|
||||
if (this.busy.has(target.id)) throw new GuardedInputConflict('Another guarded input operation is in progress');
|
||||
this.busy.add(target.id);
|
||||
try {
|
||||
if (receipt.expiresAt <= this.now() || receipt.sha256 !== confirmSha)
|
||||
throw new GuardedInputConflict('Expired stage receipt or confirmation hash mismatch');
|
||||
const quiet = receipt.typedAt + SUBMIT_QUIET_MS - this.now();
|
||||
if (quiet > 0) await this.sleep(quiet);
|
||||
const current = target.snapshot();
|
||||
if (
|
||||
!current ||
|
||||
current.generation !== receipt.generation ||
|
||||
current.revision !== receipt.revision ||
|
||||
current.availableColumns !== receipt.availableColumns ||
|
||||
current.composerSha !== receipt.sha256
|
||||
)
|
||||
throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted');
|
||||
if (!(await target.submit(current)))
|
||||
throw new GuardedInputConflict('Submission delivery failed or ambiguous; receipt consumed, inspect the pane');
|
||||
return { stageId, sessionId: target.id, submitted: true as const };
|
||||
} finally {
|
||||
this.busy.delete(target.id);
|
||||
}
|
||||
}
|
||||
clear(): void {
|
||||
this.receipts.clear();
|
||||
this.busy.clear();
|
||||
}
|
||||
}
|
||||
+310
-37
@@ -2,16 +2,21 @@
|
||||
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
|
||||
* inside a Codeman session, in every CLI mode.
|
||||
*
|
||||
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`, and the
|
||||
* agent inbox: `inbox`, `inbox/ack`, `agent-inbox/summary`). It
|
||||
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
|
||||
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* A thin HTTP client over the server's endpoints (`quick-start` for a named
|
||||
* case, `POST sessions` + `sessions/:id/interactive|shell` for a worker in an
|
||||
* existing directory, `input`, `wait`, `wait-output`, `last-response`, `terminal`,
|
||||
* `DELETE sessions/:id`, the agent inbox: `inbox`, `inbox/ack`,
|
||||
* `agent-inbox/summary`, and the experimental, opt-in
|
||||
* `inspect`/`stage`/`submit` of `web/routes/agent-session-routes.ts`, which answer 403
|
||||
* until `agentCoordinationEnabled` is on; that refusal is printed verbatim). It uses no
|
||||
* transport of its own: everything goes through `CODEMAN_API_URL`, so auth, ownership
|
||||
* and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
|
||||
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
|
||||
* preamble — has the same verbs from one line in its AGENTS.md.
|
||||
*
|
||||
* Invariants (each asserted in `test/cli-agent.test.ts`):
|
||||
* Invariants (each asserted in `test/cli-agent.test.ts` or
|
||||
* `test/cli-agent-project-spawn.test.ts`):
|
||||
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
|
||||
* never guesses a URL — a server you are not part of is not yours to drive.
|
||||
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
|
||||
@@ -23,14 +28,26 @@
|
||||
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
|
||||
* verb. `9` would resolve to whichever session is alone with that first
|
||||
* character, the user's own interactive tab included.
|
||||
* 5. `spawn` without a case starts the worker in an existing directory (cwd, or
|
||||
* `--working-dir`) via `POST sessions` + `interactive|shell`, without the
|
||||
* `X-Codeman-Agent-Origin` header, so no agent-case marker is written. It refuses
|
||||
* (exit 4, before any request) an empty `--working-dir` and a bare spawn from a
|
||||
* HOME cwd: it always sends an explicit `workingDir`, so the server's skip of
|
||||
* workspace hooks for a request that names none cannot protect `~/.claude`.
|
||||
*
|
||||
* Commands live here as functions returning an exit code, not calling
|
||||
* `process.exit`, so the whole surface is unit-testable against a fake server.
|
||||
*
|
||||
* @module cli-agent
|
||||
*/
|
||||
import { open, realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
import { STAGE_MAX_BYTES } from './agent-staging.js';
|
||||
import { INSPECT_MAX_BYTES } from './agent-inspection.js';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
basicAuthHeader,
|
||||
@@ -343,6 +360,18 @@ export const httpRequest: ApiRequest = (ctx, options) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* One line describing a thrown request error. Node 22 rejects a refused connection to a host
|
||||
* that resolves to both ::1 and 127.0.0.1 with an AggregateError whose `message` is empty, so
|
||||
* fall back to its `code` (ECONNREFUSED) before a generic line rather than printing nothing.
|
||||
*/
|
||||
export function describeRequestError(error: unknown): string {
|
||||
const message = getErrorMessage(error);
|
||||
if (message) return message;
|
||||
const code = (error as { code?: unknown } | null | undefined)?.code;
|
||||
return code ? String(code) : 'request failed without a message';
|
||||
}
|
||||
|
||||
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
|
||||
export function describeFailure(res: ApiResponse): string {
|
||||
if (res.json && !res.json.success) {
|
||||
@@ -372,7 +401,13 @@ export interface AgentDeps {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
|
||||
/**
|
||||
* Print `data` as JSON (the `--json` path): the envelope's `data`, never a reshaped copy,
|
||||
* except a directory spawn, which prints `{ sessionId, workingDir, created: true }` plus
|
||||
* `ready` when a readiness wait ran, `readinessError` when that call failed, and
|
||||
* `startupError` when its start fails, because `POST /api/sessions` answers with the
|
||||
* whole session row.
|
||||
*/
|
||||
function emitJson(deps: AgentDeps, data: unknown): void {
|
||||
deps.io.out(JSON.stringify(data, null, 2));
|
||||
}
|
||||
@@ -509,7 +544,8 @@ export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
caseName: string;
|
||||
caseName?: string;
|
||||
workingDir?: string;
|
||||
mode: string;
|
||||
name?: string;
|
||||
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
|
||||
@@ -532,44 +568,135 @@ export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cl
|
||||
export function composerReadyMark(mode: string): string | undefined {
|
||||
return getCli(mode)?.capabilities.composerReadyMark;
|
||||
}
|
||||
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
|
||||
|
||||
/** realpath, or the plain resolved path when it cannot be resolved; never throws. */
|
||||
async function canonicalPath(path: string): Promise<string> {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `directory` is the user's home directory, comparing realpaths so a symlinked
|
||||
* HOME (or a cwd reached through a symlink) still matches. False when HOME is unknown.
|
||||
*/
|
||||
export async function isHomeDirectory(directory: string): Promise<boolean> {
|
||||
let home: string;
|
||||
try {
|
||||
home = homedir();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!home) return false;
|
||||
const [a, b] = await Promise.all([canonicalPath(directory), canonicalPath(home)]);
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/** `agent spawn`: start in a project directory or named case, preserving lineage and mode readiness. */
|
||||
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
|
||||
const cli = getCli(options.mode);
|
||||
if (!cli) return fail(deps, `Unknown mode: ${options.mode}`, EXIT.refused);
|
||||
if (options.caseName !== undefined && options.workingDir !== undefined)
|
||||
return fail(deps, 'Use a case OR --working-dir, not both', EXIT.refused);
|
||||
// Same rule for the flag: `--working-dir "$WT"` with WT empty (a failed
|
||||
// `git worktree add`) must not fall through to the caller's own checkout, which
|
||||
// `resolve('')` would make it.
|
||||
if (options.workingDir !== undefined && options.workingDir.trim() === '')
|
||||
return fail(deps, 'refusing: --working-dir is empty; pass an existing directory or omit the flag', EXIT.refused);
|
||||
// Only an OMITTED case means "use a directory": an empty one (`spawn "$CASE"` with
|
||||
// CASE unset) still goes to quick-start, whose schema refuses it.
|
||||
const workingDir = options.caseName !== undefined ? undefined : resolve(options.workingDir ?? process.cwd());
|
||||
if (workingDir) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f\x7f]/.test(workingDir))
|
||||
return fail(deps, 'Working directory contains control characters', EXIT.refused);
|
||||
try {
|
||||
if (!(await stat(workingDir)).isDirectory()) throw new Error('not a directory');
|
||||
} catch {
|
||||
return fail(deps, 'Working directory must be an existing local directory', EXIT.refused);
|
||||
}
|
||||
// The server installs workspace hooks (and, with agent skill injection on, the
|
||||
// skill) into the directory it is given, and it skips that only when the caller
|
||||
// names no workingDir, because the cwd fallback is $HOME under installer-created
|
||||
// services. This CLI always sends its directory explicitly, so an implicit cwd of
|
||||
// HOME must be refused here or it would write ~/.claude/settings.local.json and
|
||||
// ~/.claude/skills. An explicit `--working-dir ~` is the caller's choice and stays.
|
||||
if (options.workingDir === undefined && (await isHomeDirectory(workingDir)))
|
||||
return fail(
|
||||
deps,
|
||||
'refusing: the current directory is your home directory; pass --working-dir ~ to start a worker there on purpose',
|
||||
EXIT.refused
|
||||
);
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
caseName: options.caseName,
|
||||
...(workingDir ? { workingDir } : { caseName: options.caseName }),
|
||||
mode: options.mode,
|
||||
parentSessionId: deps.ctx.selfId,
|
||||
};
|
||||
if (options.name) body.sessionName = options.name;
|
||||
if (options.name) body[workingDir ? 'name' : 'sessionName'] = options.name;
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: '/api/v1/quick-start',
|
||||
path: workingDir ? '/api/v1/sessions' : '/api/v1/quick-start',
|
||||
body,
|
||||
headers: { ...AGENT_ORIGIN_HEADER },
|
||||
headers: workingDir ? undefined : { ...AGENT_ORIGIN_HEADER },
|
||||
});
|
||||
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
|
||||
const created = res.json?.data as
|
||||
| { session?: { id?: string }; sessionId?: string; caseName?: string; casePath?: string }
|
||||
| undefined;
|
||||
// `POST /api/sessions` answers `{ session: { id, ... } }`; quick-start answers `{ sessionId, ... }`.
|
||||
const createdId = created?.session?.id;
|
||||
const data:
|
||||
| { sessionId?: string; caseName?: string; casePath?: string; workingDir?: string; created?: boolean }
|
||||
| undefined = workingDir && createdId ? { sessionId: createdId, workingDir, created: true } : created;
|
||||
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
|
||||
const sid = data.sessionId;
|
||||
if (workingDir) {
|
||||
// Creation alone leaves a session without a pane. Report the exact created id
|
||||
// on an ambiguous or rejected start; never retry a start or hide a leaked tab.
|
||||
let startupError: string | undefined;
|
||||
try {
|
||||
const started = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(sid)}/${cli.capabilities.startMode}`,
|
||||
});
|
||||
if (!started.json?.success) startupError = describeFailure(started);
|
||||
} catch (error) {
|
||||
startupError = getErrorMessage(error) || 'Startup request failed';
|
||||
}
|
||||
if (startupError) {
|
||||
if (deps.json) emitJson(deps, { ...data, ready: false, startupError });
|
||||
else deps.io.out(sid);
|
||||
deps.io.err(`Session ${sid} was created but startup failed or is ambiguous: ${startupError}`);
|
||||
return EXIT.error;
|
||||
}
|
||||
}
|
||||
|
||||
let ready: boolean | undefined;
|
||||
let readinessError: string | undefined;
|
||||
let dead = false;
|
||||
const mark = composerReadyMark(options.mode);
|
||||
if (options.ready && mark) {
|
||||
const wait = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
|
||||
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
});
|
||||
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
|
||||
// composer never showed up": report the real reason instead of the trust-dialog hint.
|
||||
if (!wait.json?.success) readinessError = describeFailure(wait);
|
||||
else {
|
||||
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
|
||||
// A worker that died while we waited is exit 3 like every other wait, not a
|
||||
// "composer not seen" timeout that sends the caller looking for a dialog.
|
||||
dead = waitExitCode(result) === EXIT.dead;
|
||||
ready = !dead && Boolean(result?.matched);
|
||||
try {
|
||||
const wait = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
|
||||
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
});
|
||||
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
|
||||
// composer never showed up": report the real reason instead of the trust-dialog hint.
|
||||
if (!wait.json?.success) readinessError = describeFailure(wait);
|
||||
else {
|
||||
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
|
||||
// A worker that died while we waited is exit 3 like every other wait, not a
|
||||
// "composer not seen" timeout that sends the caller looking for a dialog.
|
||||
dead = waitExitCode(result) === EXIT.dead;
|
||||
ready = !dead && Boolean(result?.matched);
|
||||
}
|
||||
} catch (error) {
|
||||
readinessError = describeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,7 +705,7 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis
|
||||
} else {
|
||||
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
|
||||
const say = (line: string) => deps.io.err(line);
|
||||
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
|
||||
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, ${workingDir ?? data.caseName ?? options.caseName})`));
|
||||
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
|
||||
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
|
||||
if (ready === false && !dead) {
|
||||
@@ -757,6 +884,103 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
|
||||
return waitExitCode(data?.wait);
|
||||
}
|
||||
|
||||
/** Bounded incremental evidence, with an opaque server-owned cursor. */
|
||||
export async function agentInspect(
|
||||
deps: AgentDeps,
|
||||
options: { id: string; maxBytes: number; since?: string }
|
||||
): Promise<number> {
|
||||
if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1 || options.maxBytes > INSPECT_MAX_BYTES)
|
||||
return fail(deps, '--max-bytes must be between 1 and 65536', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/inspect`,
|
||||
query: { maxBytes: options.maxBytes, since: options.since },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else {
|
||||
const data = response.json.data as {
|
||||
source?: string;
|
||||
evidence?: string;
|
||||
truncated?: boolean;
|
||||
cursor?: string;
|
||||
blockedReason?: string;
|
||||
};
|
||||
deps.io.out(
|
||||
`[${data.source ?? 'unknown'}]${data.truncated ? ' truncated' : ''}${data.blockedReason ? ` (${data.blockedReason})` : ''}`
|
||||
);
|
||||
deps.io.out(data.evidence ?? '');
|
||||
if (data.cursor) deps.io.out(`cursor: ${data.cursor}`);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** Stage exactly the printable UTF8 file contents; never sends Enter. */
|
||||
export async function agentStage(deps: AgentDeps, options: { id: string; messageFile: string }): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||||
let message: string;
|
||||
try {
|
||||
const info = await stat(options.messageFile);
|
||||
if (!info.isFile() || info.size > STAGE_MAX_BYTES)
|
||||
return fail(deps, 'message file must be a regular file within 8192 UTF8 bytes', EXIT.refused);
|
||||
const handle = await open(options.messageFile, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(STAGE_MAX_BYTES + 1);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const bytes = buffer.subarray(0, bytesRead);
|
||||
if (bytesRead > STAGE_MAX_BYTES || !isUtf8(bytes))
|
||||
return fail(deps, 'message file must contain at most 8192 bytes of UTF8', EXIT.refused);
|
||||
message = bytes.toString('utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(deps, `cannot read message file: ${getErrorMessage(error)}`, EXIT.refused);
|
||||
}
|
||||
const refusal = inputRefusal(message);
|
||||
if (refusal || message !== message.trimEnd())
|
||||
return fail(deps, refusal ?? 'stage refuses trailing whitespace', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/stage`,
|
||||
body: { message },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else {
|
||||
const data = response.json.data as { stageId: string; sha256: string; expiresAt: number };
|
||||
deps.io.out(
|
||||
`staged (no Enter): ${data.stageId}\nsha256: ${data.sha256}\nexpires: ${new Date(data.expiresAt).toISOString()}`
|
||||
);
|
||||
}
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** Submit one verified staged composer; failure never automatically replays Enter. */
|
||||
export async function agentSubmit(
|
||||
deps: AgentDeps,
|
||||
options: { id: string; stageId: string; confirmSha: string }
|
||||
): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||||
if (!/^[a-f0-9]{64}$/.test(options.confirmSha) || !/^[a-f0-9-]{36}$/.test(options.stageId))
|
||||
return fail(deps, 'submit requires a stage UUID and its exact SHA-256', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const response = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/submit`,
|
||||
body: { stageId: options.stageId, confirmSha: options.confirmSha },
|
||||
});
|
||||
if (!response.json?.success) return fail(deps, describeFailure(response));
|
||||
if (deps.json) emitJson(deps, response.json.data);
|
||||
else deps.io.out('Enter delivered once; inspect or wait for acceptance (no automatic retry).');
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
id: string;
|
||||
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
|
||||
@@ -1038,7 +1262,7 @@ async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): P
|
||||
try {
|
||||
process.exitCode = await verb(deps);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`${GLYPH.fail} ${describeRequestError(err)}`));
|
||||
process.exitCode = EXIT.error;
|
||||
}
|
||||
}
|
||||
@@ -1048,7 +1272,7 @@ export function registerAgentCommands(program: Command): Command {
|
||||
const agent = program
|
||||
.command('agent')
|
||||
.description(
|
||||
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, and the inbox: post, inbox, ack'
|
||||
'Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm, the inbox: post, inbox, ack, and the experimental, opt-in inspect, stage, submit'
|
||||
);
|
||||
|
||||
agent
|
||||
@@ -1059,20 +1283,26 @@ export function registerAgentCommands(program: Command): Command {
|
||||
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
|
||||
|
||||
agent
|
||||
.command('spawn <case>')
|
||||
.description(
|
||||
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
|
||||
)
|
||||
.command('spawn [case]')
|
||||
.description('Start a worker in cwd, an existing directory, or a named case; wait for its composer where supported')
|
||||
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
|
||||
.option('-n, --name <name>', 'Session name shown in the UI')
|
||||
.option(
|
||||
'--working-dir <directory>',
|
||||
'Existing project directory (defaults to cwd when no case is given; a cwd of HOME is refused, pass it explicitly)'
|
||||
)
|
||||
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
|
||||
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action(
|
||||
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
|
||||
(
|
||||
caseName: string | undefined,
|
||||
options: { mode: string; name?: string; workingDir?: string; ready: boolean; timeout?: string; json?: boolean }
|
||||
) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentSpawn(deps, {
|
||||
caseName,
|
||||
workingDir: options.workingDir,
|
||||
mode: options.mode,
|
||||
name: options.name,
|
||||
ready: options.ready,
|
||||
@@ -1169,6 +1399,49 @@ export function registerAgentCommands(program: Command): Command {
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('inspect <id>')
|
||||
.description(
|
||||
'Experimental, opt-in (agentCoordinationEnabled): bounded transcript evidence or a labeled pane snapshot; opaque incremental cursors'
|
||||
)
|
||||
.option('--max-bytes <n>', 'UTF8 evidence budget (1–65536)', '8192')
|
||||
.option('--since <cursor>', 'Resume a cursor from the same owner, pane and transcript')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { maxBytes: string; since?: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentInspect(deps, {
|
||||
id,
|
||||
maxBytes: parsePositiveInt(options.maxBytes, 8192, '--max-bytes'),
|
||||
since: options.since,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('stage <id>')
|
||||
.description(
|
||||
'Experimental, opt-in (agentCoordinationEnabled): type a printable single-line UTF8 file without Enter; requires a verifiable local Codex composer, and refuses slash or shell commands and a last word starting with @ or $ (use send)'
|
||||
)
|
||||
.requiredOption('--message-file <path>', 'Regular UTF8 file, at most 8192 bytes, no trailing newline')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { messageFile: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentStage(deps, { id, messageFile: options.messageFile }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('submit <id>')
|
||||
.description(
|
||||
'Experimental, opt-in (agentCoordinationEnabled): send Enter once only when the staged composer, pane and hash still match'
|
||||
)
|
||||
.requiredOption('--stage-id <receipt>', 'One-shot stage receipt')
|
||||
.requiredOption('--confirm-sha <hash>', 'Exact SHA-256 returned by stage')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { stageId: string; confirmSha: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentSubmit(deps, { id, stageId: options.stageId, confirmSha: options.confirmSha })
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('read <id>')
|
||||
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview `codeman history read`: the command-line front of the offline
|
||||
* Codex history reader (`local-history.ts`).
|
||||
*
|
||||
* Deliberately separate from `codeman agent` (`cli-agent.ts`): that command is
|
||||
* a client of a running Codeman and refuses outside a managed session, while
|
||||
* this one reads rollout files from disk and needs neither a server nor
|
||||
* `CODEMAN_MUX`. It never writes, never inspects a live pane and never sends
|
||||
* input.
|
||||
*
|
||||
* Every bound the reader enforces has a flag here with the reader's own
|
||||
* maximum, so a large store is a matter of raising a number rather than an
|
||||
* impossible lookup. Output is JSON unless `--format text` is given; `--json`
|
||||
* is the JSON spelling and refuses to combine with `--format text`.
|
||||
*
|
||||
* @module cli-history
|
||||
*/
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
formatLocalHistory,
|
||||
HISTORY_DEFAULT_MAX_ENTRIES,
|
||||
HISTORY_DEFAULT_METADATA_BYTES,
|
||||
HISTORY_DEFAULT_OUTPUT_BYTES,
|
||||
HISTORY_DEFAULT_SCAN_BYTES,
|
||||
HISTORY_MAX_ENTRIES,
|
||||
HISTORY_MAX_METADATA_BYTES,
|
||||
HISTORY_MAX_OUTPUT_BYTES,
|
||||
HISTORY_MAX_SCAN_BYTES,
|
||||
readLocalHistory,
|
||||
validateOutputBytes,
|
||||
} from './local-history.js';
|
||||
|
||||
/**
|
||||
* A budget flag's value as a number. Only plain decimal digits count, so `0x10`,
|
||||
* `1e3` and ` 5 ` are refused rather than read as 16, 1000 and 5; anything else
|
||||
* becomes NaN, which the reader's range check rejects with the flag's name.
|
||||
*/
|
||||
function budgetFlag(value: string | boolean | undefined): number {
|
||||
return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
}
|
||||
|
||||
export function registerHistoryCommands(program: Command): Command {
|
||||
const history = program
|
||||
.command('history')
|
||||
.description('Read local saved native transcripts without a running server');
|
||||
history
|
||||
.command('read')
|
||||
.description('Read Codex history by file, native id, or root conversation and descendants')
|
||||
.option('--file <path>', 'Explicit local JSONL transcript (regular file only)')
|
||||
.option('--session-id <id>', 'Native Codex conversation UUID or unique prefix (at least 8 characters)')
|
||||
.option('--root-session-id <uuid>', 'Native root UUID, including transitive child conversations')
|
||||
.option(
|
||||
'--sessions-root <path>',
|
||||
'Local Codex sessions directory (default: $CODEX_HOME/sessions, else ~/.codex/sessions)'
|
||||
)
|
||||
.option('--topic <text>', 'Case-insensitive literal match, preserving whole matching turns')
|
||||
.option('--after <date>', 'Inclusive YYYY-MM-DD lower bound, as a UTC date; preserves whole matching turns')
|
||||
.option('--before <date>', 'Inclusive YYYY-MM-DD upper bound, as a UTC date; preserves whole matching turns')
|
||||
.option(
|
||||
'--max-bytes <n>',
|
||||
`Maximum complete output bytes (1-${HISTORY_MAX_OUTPUT_BYTES})`,
|
||||
String(HISTORY_DEFAULT_OUTPUT_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--scan-bytes <n>',
|
||||
`Read budget for the selected transcripts (1-${HISTORY_MAX_SCAN_BYTES})`,
|
||||
String(HISTORY_DEFAULT_SCAN_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--metadata-bytes <n>',
|
||||
`Read budget for attributing every rollout in the store (1-${HISTORY_MAX_METADATA_BYTES})`,
|
||||
String(HISTORY_DEFAULT_METADATA_BYTES)
|
||||
)
|
||||
.option(
|
||||
'--max-entries <n>',
|
||||
`Directory entries visited in the store (1-${HISTORY_MAX_ENTRIES})`,
|
||||
String(HISTORY_DEFAULT_MAX_ENTRIES)
|
||||
)
|
||||
.option('--format <format>', 'json or text', 'json')
|
||||
.option('--json', 'JSON output (the default; conflicts with --format text)')
|
||||
.action(async (options: Record<string, string | boolean | undefined>, command: Command) => {
|
||||
try {
|
||||
if (options.format !== 'json' && options.format !== 'text') throw new Error('--format must be json or text');
|
||||
if (options.json && options.format === 'text' && command.getOptionValueSource('format') === 'cli')
|
||||
throw new Error('--json conflicts with --format text; pass one of them');
|
||||
const format = options.format;
|
||||
// Every flag is checked before the first file is read: --max-bytes would otherwise
|
||||
// only be judged after the whole store had been scanned.
|
||||
const maxBytes = budgetFlag(options.maxBytes);
|
||||
validateOutputBytes(maxBytes);
|
||||
const result = await readLocalHistory({
|
||||
file: options.file as string | undefined,
|
||||
sessionId: options.sessionId as string | undefined,
|
||||
rootSessionId: options.rootSessionId as string | undefined,
|
||||
sessionsRoot: options.sessionsRoot as string | undefined,
|
||||
topic: options.topic as string | undefined,
|
||||
after: options.after as string | undefined,
|
||||
before: options.before as string | undefined,
|
||||
scanBytes: budgetFlag(options.scanBytes),
|
||||
metadataBytes: budgetFlag(options.metadataBytes),
|
||||
maxEntries: budgetFlag(options.maxEntries),
|
||||
});
|
||||
program.configureOutput().writeOut?.(`${formatLocalHistory(result, format, maxBytes)}\n`);
|
||||
} catch (error) {
|
||||
program.error(error instanceof Error ? error.message : String(error), { exitCode: 1, code: 'history.read' });
|
||||
}
|
||||
});
|
||||
return history;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
|
||||
*
|
||||
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
|
||||
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
|
||||
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
|
||||
* print and the exit code instead of exiting, so a test can drive it.
|
||||
*
|
||||
* @module cli-notify
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface NotifyOptions {
|
||||
message: string;
|
||||
urgent?: boolean;
|
||||
file?: string;
|
||||
session?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
code: number;
|
||||
out?: string;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export type JsonPost = (
|
||||
apiUrl: string,
|
||||
apiPath: string,
|
||||
payload: unknown,
|
||||
timeoutMs?: number
|
||||
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
|
||||
|
||||
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
|
||||
export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(apiPath, apiUrl);
|
||||
const body = JSON.stringify(payload);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolveOnce) => {
|
||||
// Settle once: a response that stalls after its headers ends in 'close' without 'end'.
|
||||
let settled = false;
|
||||
const resolve: typeof resolveOnce = (v) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveOnce(v);
|
||||
};
|
||||
const headers: Record<string, string | number> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: 'POST',
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('error', () => resolve({ status: 0 }));
|
||||
res.on('close', () => resolve({ status: 0 }));
|
||||
res.on('end', () => {
|
||||
let error: string | undefined;
|
||||
let delivery: { ok: boolean; error?: string } | undefined;
|
||||
try {
|
||||
const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
|
||||
error = body.error;
|
||||
// A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
|
||||
if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, error, delivery });
|
||||
});
|
||||
}
|
||||
);
|
||||
// A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0 }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
export async function runNotify(
|
||||
opts: NotifyOptions,
|
||||
env: NodeJS.ProcessEnv,
|
||||
cwd: string,
|
||||
post: JsonPost = postJson
|
||||
): Promise<NotifyResult> {
|
||||
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = opts.url || env.CODEMAN_API_URL;
|
||||
if (!sessionId || !apiUrl) {
|
||||
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
|
||||
}
|
||||
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
|
||||
if (opts.urgent) payload.urgent = true;
|
||||
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
|
||||
const { status, error, delivery } = await post(
|
||||
apiUrl,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
|
||||
payload
|
||||
);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (delivery?.ok === false) {
|
||||
return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
|
||||
}
|
||||
return { code: 0, out: 'Posted to Discord' };
|
||||
}
|
||||
// The bridge being off is not a failure: the caller just says it in the terminal.
|
||||
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
|
||||
if (status === 429)
|
||||
return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
|
||||
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
|
||||
}
|
||||
+36
-1
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { runNotify } from './cli-notify.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import type { AgentSkillDir } from './config/cli-registry/types.js';
|
||||
import { getCli, listClis } from './config/cli-registry/registry.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { registerHistoryCommands } from './cli-history.js';
|
||||
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
@@ -91,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
|
||||
});
|
||||
}
|
||||
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description(
|
||||
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
|
||||
)
|
||||
.option('--urgent', 'Ping the user')
|
||||
.option('--file <path>', 'Send a file from the session folder with the message')
|
||||
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
|
||||
.action(async (message: string, options) => {
|
||||
const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
|
||||
if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
|
||||
if (err) console.error(palette.err(`✗ ${err}`));
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
program
|
||||
.command('attach <path>')
|
||||
.description('Show an attachment card for a local file')
|
||||
@@ -324,6 +342,7 @@ skillCmd
|
||||
// ============ Agent Commands (session-to-session, any CLI mode) ============
|
||||
|
||||
registerAgentCommands(program);
|
||||
registerHistoryCommands(program);
|
||||
|
||||
// ============ MCP server (any MCP client drives Codeman) ============
|
||||
|
||||
@@ -950,6 +969,10 @@ function addWebLaunchOptions(cmd: Command): Command {
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option(
|
||||
'--allow-unauthenticated-tunnel',
|
||||
'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)'
|
||||
)
|
||||
.option(
|
||||
'--multiuser',
|
||||
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
|
||||
@@ -964,6 +987,7 @@ function toWebLaunchOptions(options: {
|
||||
baseUrl?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
allowUnauthenticatedTunnel?: boolean;
|
||||
multiuser?: boolean;
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
@@ -985,6 +1009,7 @@ function toWebLaunchOptions(options: {
|
||||
basePath,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
allowUnauthenticatedTunnel: !!options.allowUnauthenticatedTunnel,
|
||||
multiuser: !!options.multiuser,
|
||||
};
|
||||
}
|
||||
@@ -1072,6 +1097,7 @@ webCmd.action(async (options) => {
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const allowUnauthenticatedTunnel = launch.allowUnauthenticatedTunnel ?? false;
|
||||
const basePath = launch.basePath ?? '';
|
||||
// Single source of truth for subsystems that read it directly (e.g. renderers).
|
||||
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
|
||||
@@ -1086,7 +1112,16 @@ webCmd.action(async (options) => {
|
||||
try {
|
||||
// The server prints its own "running at" line (it also covers the daemon and
|
||||
// service launch paths), so this one used to be a duplicate of it.
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
|
||||
const server = await startWebServer(
|
||||
port,
|
||||
https,
|
||||
false,
|
||||
host,
|
||||
titleHostname,
|
||||
allowUnauthenticatedNetwork,
|
||||
basePath,
|
||||
allowUnauthenticatedTunnel
|
||||
);
|
||||
if (https) {
|
||||
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @fileoverview Shape helpers for Codex rollout JSONL lines, shared by the two
|
||||
* readers of `~/.codex/sessions`: the Past Sessions scanner
|
||||
* (`codex-transcript.ts`, previews only) and the offline history reader
|
||||
* (`local-history.ts`, whole conversations).
|
||||
*
|
||||
* Both readers must agree on three facts, so they live here once:
|
||||
*
|
||||
* - where the store is (`$CODEX_HOME/sessions`, else `~/.codex/sessions`);
|
||||
* - which line shapes carry a user or assistant message;
|
||||
* - which `response_item` user rows are injected context rather than input.
|
||||
*
|
||||
* ## The message shapes
|
||||
*
|
||||
* Codex has written conversation text under three shapes:
|
||||
*
|
||||
* - `event_msg` / `user_message` and `agent_message`, written by every version
|
||||
* seen so far. `agent_message` carries a `phase` (`commentary` or
|
||||
* `final_answer`); `user_message` carries none.
|
||||
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage` or
|
||||
* `AgentMessage`, written by newer versions (0.152.1 and later).
|
||||
* - `response_item` rows with `role: 'user'` or `'assistant'`. These are what
|
||||
* the model saw, so the user rows mix real input with injected context.
|
||||
*
|
||||
* ## The injected-context rule
|
||||
*
|
||||
* Codex injects context as `response_item` user rows that start with a Markdown
|
||||
* heading (`# AGENTS.md instructions for ...`) or an XML-style tag
|
||||
* (`<environment_context>`, `<recommended_plugins>`, `<subagent_notification>`,
|
||||
* `<skill>`, `<turn_aborted>`, `<image ...>`, `<codex_internal_context ...>`).
|
||||
* Measured on a store of 2,916 rollouts (codex 0.120 to 0.163), every
|
||||
* `response_item` user row with either prefix and no event twin was injected;
|
||||
* the real prompts that start with `#` all have a `user_message` or
|
||||
* `UserMessage` event twin, which this rule never filters. A fixed list of tag
|
||||
* names would leak every wrapper Codex adds later (`<recommended_plugins>`
|
||||
* alone was 1,355 rows on that store), so the rule is the prefix.
|
||||
*
|
||||
* @module codex-rollout-format
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** Codex's home directory: `$CODEX_HOME`, else `~/.codex`, exactly as codex resolves it. */
|
||||
export function codexHomeDir(): string {
|
||||
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
}
|
||||
|
||||
/** The rollout store under {@link codexHomeDir}. */
|
||||
export function codexSessionsRoot(): string {
|
||||
return join(codexHomeDir(), 'sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten Codex message content, which is a string or an array of blocks.
|
||||
* Only blocks carrying a string `text` contribute (images and skills do not).
|
||||
* Not trimmed: callers that want a preview trim.
|
||||
*/
|
||||
export function codexContentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Injected context rather than typed input. Applies to `response_item` user rows only. */
|
||||
export function isCodexInjectedContext(text: string): boolean {
|
||||
const start = text.trimStart();
|
||||
return start.startsWith('#') || start.startsWith('<');
|
||||
}
|
||||
|
||||
/** One rollout line's user or assistant message, whichever shape carried it. */
|
||||
export interface CodexLineMessage {
|
||||
role: 'user' | 'assistant';
|
||||
/** Untrimmed text, never empty. */
|
||||
text: string;
|
||||
/** `event` = user_message/agent_message, `item` = item_completed, `response` = response_item. */
|
||||
shape: 'event' | 'item' | 'response';
|
||||
/** Codex's own phase (`commentary` / `final_answer`) when the line carries one. */
|
||||
phase?: string;
|
||||
/** A `response_item` user row: may be injected context, see {@link isCodexInjectedContext}. */
|
||||
injectionProne: boolean;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function phaseOf(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
/** The message a parsed rollout line carries, or null for every other line. */
|
||||
export function codexMessageFromLine(entry: unknown): CodexLineMessage | null {
|
||||
const e = record(entry);
|
||||
const p = record(e.payload);
|
||||
let role: CodexLineMessage['role'] | undefined;
|
||||
let text = '';
|
||||
let shape: CodexLineMessage['shape'] = 'event';
|
||||
let phase: string | undefined;
|
||||
if (e.type === 'event_msg') {
|
||||
if (p.type === 'user_message' || p.type === 'agent_message') {
|
||||
role = p.type === 'user_message' ? 'user' : 'assistant';
|
||||
text = codexContentText(p.message);
|
||||
phase = phaseOf(p.phase);
|
||||
} else if (p.type === 'item_completed') {
|
||||
const item = record(p.item);
|
||||
if (item.type === 'UserMessage' || item.type === 'AgentMessage') {
|
||||
role = item.type === 'UserMessage' ? 'user' : 'assistant';
|
||||
shape = 'item';
|
||||
text = codexContentText(item.content);
|
||||
phase = phaseOf(item.phase);
|
||||
}
|
||||
}
|
||||
} else if (e.type === 'response_item' && (p.role === 'user' || p.role === 'assistant')) {
|
||||
role = p.role;
|
||||
shape = 'response';
|
||||
text = codexContentText(p.content);
|
||||
phase = phaseOf(p.phase);
|
||||
}
|
||||
if (!role || !text) return null;
|
||||
return { role, text, shape, phase, injectionProne: shape === 'response' && role === 'user' };
|
||||
}
|
||||
+16
-53
@@ -37,13 +37,20 @@
|
||||
* summaries), so they are read only when neither shape above appears, and
|
||||
* the obvious injections are dropped.
|
||||
*
|
||||
* The shapes, the injected-context rule and the store location live in
|
||||
* `codex-rollout-format.ts`, shared with the offline reader `local-history.ts`.
|
||||
*
|
||||
* @module codex-transcript
|
||||
*/
|
||||
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
codexMessageFromLine,
|
||||
codexSessionsRoot,
|
||||
isCodexInjectedContext as isInjectedContext,
|
||||
} from './codex-rollout-format.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
|
||||
@@ -134,11 +141,6 @@ function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boole
|
||||
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
|
||||
}
|
||||
|
||||
function codexSessionsRoot(): string {
|
||||
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
return join(home, 'sessions');
|
||||
}
|
||||
|
||||
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
|
||||
async function readHead(path: string, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
@@ -176,55 +178,16 @@ async function readTail(path: string, size: number, bytes: number): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten codex's message content, which is a string or an array of text blocks. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** One line's user-prompt text, whichever of the three shapes it is. */
|
||||
function userPromptFromLine(entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
}): { text: string; injectionProne: boolean } | null {
|
||||
const p = entry.payload;
|
||||
if (!p) return null;
|
||||
|
||||
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
|
||||
const text = contentText(p.item.content);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'event_msg' && p.type === 'user_message') {
|
||||
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'response_item' && p.role === 'user') {
|
||||
const text = contentText(p.content);
|
||||
return text ? { text, injectionProne: true } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context rather than something the user typed. Codex prepends the
|
||||
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
|
||||
* as `response_item` user rows.
|
||||
* One line's user-prompt text, whichever of the three shapes it is. The shapes
|
||||
* and the injected-context rule are shared with the offline history reader
|
||||
* (`codex-rollout-format.ts`), so the two never disagree about what a prompt is.
|
||||
*/
|
||||
function isInjectedContext(text: string): boolean {
|
||||
return text.startsWith('#') || text.startsWith('<');
|
||||
function userPromptFromLine(entry: unknown): { text: string; injectionProne: boolean } | null {
|
||||
const message = codexMessageFromLine(entry);
|
||||
if (!message || message.role !== 'user') return null;
|
||||
const text = message.text.trim();
|
||||
return text ? { text, injectionProne: message.injectionProne } : null;
|
||||
}
|
||||
|
||||
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
|
||||
|
||||
@@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [
|
||||
'clis.json',
|
||||
'custom-model-hosts.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'remote-hosts.json',
|
||||
@@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
|
||||
'users.json',
|
||||
'push-keys.json',
|
||||
'webhook.json',
|
||||
'discord.json',
|
||||
'intents.json',
|
||||
]);
|
||||
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
||||
|
||||
@@ -21,6 +21,7 @@ export type TokenPattern =
|
||||
| 'model'
|
||||
| 'model-claude'
|
||||
| 'model-plain'
|
||||
| 'model-plain-colon'
|
||||
| 'model-pi'
|
||||
| 'id'
|
||||
| 'id-dotted'
|
||||
@@ -47,6 +48,10 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
||||
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
|
||||
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
|
||||
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
|
||||
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
|
||||
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
|
||||
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
|
||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||
// opencode --session, codex resume
|
||||
|
||||
@@ -80,6 +80,7 @@ const paramSpecSchema = z.union([
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -224,6 +225,7 @@ const envExportSchema = z
|
||||
'muxName',
|
||||
'effortLevel',
|
||||
'effortSettingsJson',
|
||||
'appendSystemPrompt',
|
||||
'codemanPrefixedSessionId',
|
||||
'launcherDefaultTarget',
|
||||
]),
|
||||
@@ -286,6 +288,14 @@ const launchModelTemplate = z
|
||||
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
|
||||
.optional();
|
||||
|
||||
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
|
||||
* leading alphanumeric, no traversal or hidden-dot escape. */
|
||||
const homeSegmentName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
|
||||
|
||||
const capabilitiesSchema = z
|
||||
.object({
|
||||
external: z.boolean(),
|
||||
@@ -301,6 +311,9 @@ const capabilitiesSchema = z
|
||||
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
|
||||
// Optional: absent means no paging, the safe default for an unmeasured CLI.
|
||||
transcriptPageKeys: z.boolean().optional(),
|
||||
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
||||
// Optional: absent means the keyboard resizes, the safe default.
|
||||
keyboardHold: z.literal('inline').optional(),
|
||||
keyboardAccessory: z.enum(['agent', 'shell']),
|
||||
privilegedCommandGate: z.boolean(),
|
||||
startMode: z.enum(['interactive', 'shell']),
|
||||
@@ -384,6 +397,35 @@ const capabilitiesSchema = z
|
||||
// marker but never the footer. Still bounded, since it is also how much text the
|
||||
// pattern runs over.
|
||||
blockedLines: z.number().int().min(1).max(24).optional(),
|
||||
// Same guard again: run over the rows under the composer each time guarded
|
||||
// input reads the pane, and ~/.codeman/clis.json can set it.
|
||||
composerFooterLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Same guard again: run over the rows just above the composer by the same reads.
|
||||
composerPopupLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Single ASCII punctuation characters, never letters, digits or whitespace: each one
|
||||
// refuses every message that starts (or whose last word starts) with it.
|
||||
composerCommandPrefixes: z
|
||||
.string()
|
||||
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters')
|
||||
.optional(),
|
||||
composerCompletionSigils: z
|
||||
.string()
|
||||
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters')
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||
@@ -548,10 +590,61 @@ const capabilitiesSchema = z
|
||||
kind: z.literal('configDir'),
|
||||
dirEnvVar: envName,
|
||||
fileName: z.string().min(1).max(80),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
|
||||
launchModel: launchModelTemplate,
|
||||
// State that must survive the isolated-directory redirect, symlinked by name from
|
||||
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
|
||||
// segments only: the consumer joins them, so a traversal here would escape the
|
||||
// real home. See the field's comment in cli-registry/types.ts.
|
||||
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
|
||||
// Which of `linkFromRealHome` are directories (missing ones are created before
|
||||
// linking; missing files are skipped). Same segment pattern, see types.ts.
|
||||
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
|
||||
// The real home's fallback location, relative to the user's home, ONE segment:
|
||||
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
|
||||
// bare `.` are not; the consumer joins it under the user's home, a traversal
|
||||
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
|
||||
realHomeFallback: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^(?!\.\.?$)[^/\\]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
.strict()
|
||||
.superRefine((recipe, ctx) => {
|
||||
// Both rules were comments before; the schema is the security boundary, so
|
||||
// enforce them at load time (see the header of this file).
|
||||
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
|
||||
path: ['realHomeFallback'],
|
||||
});
|
||||
}
|
||||
for (const name of recipe.linkFromRealHome ?? []) {
|
||||
if (name === recipe.fileName) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
|
||||
path: ['linkFromRealHome'],
|
||||
});
|
||||
}
|
||||
}
|
||||
// types.ts documents the dirs list as "must be a subset of linkFromRealHome";
|
||||
// a name absent from it names nothing (missing-file entries are skipped as
|
||||
// files), so the recipe would silently stop creating a directory it declares.
|
||||
const linkedNames = new Set(recipe.linkFromRealHome ?? []);
|
||||
for (const dir of recipe.linkFromRealHomeDirs ?? []) {
|
||||
if (!linkedNames.has(dir)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
|
||||
path: ['linkFromRealHomeDirs'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
z.object({ kind: z.literal('unsupported') }).strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
import type { CliEntry } from './types.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||
|
||||
/**
|
||||
* Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what
|
||||
* codex prints when no effort is configured), as one regex alternation. Shared by the two
|
||||
* patterns that read that footer (`modelDetect.screenLine` and
|
||||
* `workDetect.composerFooterLine`), so a new level is a one-place edit.
|
||||
*/
|
||||
const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|');
|
||||
|
||||
const HOME_DIRS = {
|
||||
local: '~/.local/bin',
|
||||
usrLocal: '/usr/local/bin',
|
||||
@@ -144,6 +152,7 @@ const CLAUDE: CliEntry = {
|
||||
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
|
||||
effortLevel: { type: 'engine', source: 'effortLevel' },
|
||||
effortJson: { type: 'engine', source: 'effortSettingsJson' },
|
||||
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
|
||||
sessionId: { type: 'engine', source: 'sessionId' },
|
||||
sessionName: { type: 'engine', source: 'sessionName' },
|
||||
},
|
||||
@@ -171,6 +180,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -195,6 +210,12 @@ const CLAUDE: CliEntry = {
|
||||
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
|
||||
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
|
||||
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
|
||||
{
|
||||
flag: '--append-system-prompt',
|
||||
valueFrom: 'discordHint',
|
||||
quote: 'single',
|
||||
when: { param: 'discordHint', state: 'set' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -289,6 +310,10 @@ const CLAUDE: CliEntry = {
|
||||
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
|
||||
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
|
||||
transcriptPageKeys: true,
|
||||
// Claude's default renderer is inline, and each keyboard-driven PTY height change made
|
||||
// it reprint only the tail that fits, erasing transcript rows from tmux (measured on
|
||||
// 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold.
|
||||
keyboardHold: 'inline',
|
||||
keyboardAccessory: 'agent',
|
||||
privilegedCommandGate: false,
|
||||
startMode: 'interactive',
|
||||
@@ -616,7 +641,10 @@ const CODEX: CliEntry = {
|
||||
params: {
|
||||
bypassApprovals: { type: 'bool' },
|
||||
animations: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
|
||||
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
|
||||
// `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
|
||||
model: { type: 'token', pattern: 'model-plain-colon' },
|
||||
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||
resumeId: { type: 'token', pattern: 'id' },
|
||||
},
|
||||
@@ -734,6 +762,34 @@ const CODEX: CliEntry = {
|
||||
watchingLines: 4,
|
||||
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
|
||||
blockedLines: 20,
|
||||
// The rows codex draws under an idle composer, tested one at a time by guarded
|
||||
// stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default ·
|
||||
// ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the
|
||||
// `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older
|
||||
// `NN% context left` footer. Indented, so the `›` composer row itself never matches.
|
||||
composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`,
|
||||
// Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/
|
||||
// popup_state.rs, composer_layout.rs and the popups' render code and snapshots):
|
||||
// every suggestion popup renders ABOVE the composer and keeps the footer below it,
|
||||
// so the footer alone cannot tell `› /stat` with the command list open from an
|
||||
// idle draft, and Enter then runs the highlighted `/status`. What sits in the
|
||||
// block just above the composer (past its blank top padding row):
|
||||
// command list (`/`) `› /status show current session ...`
|
||||
// ` /statusline configure which items ...`
|
||||
// skill and mention lists ` enter insert · esc close` (key-hint row)
|
||||
// (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter`
|
||||
// file search (`@`) ` loading...` / ` no matches`, else bare path
|
||||
// rows, which no pattern can tell from prose
|
||||
// The last case is why stage also refuses the triggers themselves (below).
|
||||
composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`,
|
||||
// Same source. A draft starting with `/` is a slash command (the popup opens while
|
||||
// the caret is in its name, and Enter runs a command either way), and one starting
|
||||
// with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups
|
||||
// open for the word under the caret only: `@` (file search, or the unified mention
|
||||
// list) and `$` (skills and mentions; whether a given `$word` opens it depends on
|
||||
// codex's skill catalog, so every `$` word is refused).
|
||||
composerCommandPrefixes: '/!',
|
||||
composerCompletionSigils: '@$',
|
||||
},
|
||||
// The footer under the composer, measured on a live 0.147.0 pane:
|
||||
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
|
||||
@@ -757,7 +813,7 @@ const CODEX: CliEntry = {
|
||||
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
|
||||
// an indented row, so it is not read.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${CODEX_EFFORT_WORDS}) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLines: 2,
|
||||
},
|
||||
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
|
||||
@@ -801,6 +857,7 @@ const CODEX: CliEntry = {
|
||||
dirEnvVar: 'CODEX_HOME',
|
||||
fileName: 'config.toml',
|
||||
template: 'codex-toml',
|
||||
launchModel: '{modelId}',
|
||||
},
|
||||
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||
@@ -1505,30 +1562,30 @@ const DEEPSEEK: CliEntry = {
|
||||
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
|
||||
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
|
||||
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
|
||||
// allowlisted-envOverrides path as the redirect itself.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
// The installed profile supplies the transport; a per-session patch selects its exact model.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
kind: 'configDir',
|
||||
dirEnvVar: 'DSH_HOME',
|
||||
fileName: 'cordis.patch.yml',
|
||||
template: 'deepseek-patch-json',
|
||||
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
|
||||
// session-log root (deleting it would take the user's transcripts with it on clear/
|
||||
// close), `profiles` are the installed bundles the patch composes over, and
|
||||
// `settings.yaml` must keep applying (read through the link only until the session's
|
||||
// first settings write: dsh saves by rename, which replaces the link with a local file
|
||||
// that goes away with the session). The generated home-level `cordis.patch.yml`
|
||||
// deliberately replaces the user's own for the session rather than linking it.
|
||||
// `sessions` and `profiles` are directories: dsh creates them itself on a first
|
||||
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
|
||||
// dangling link whose every write fails ENOENT: the consumer creates missing
|
||||
// directory targets and skips missing file targets like `settings.yaml`.
|
||||
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
|
||||
linkFromRealHomeDirs: ['profiles', 'sessions'],
|
||||
realHomeFallback: '.dsh',
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
@@ -1881,8 +1938,8 @@ const COPILOT: CliEntry = {
|
||||
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
|
||||
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
|
||||
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
|
||||
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1`
|
||||
// suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
|
||||
// they are privileged env keys below: a non-granted multi-user owner must not be able to
|
||||
// redirect the session's endpoint or credentials through plain envOverrides.
|
||||
customModelInjection: {
|
||||
|
||||
@@ -29,6 +29,8 @@ export type EngineValue =
|
||||
| 'muxName'
|
||||
| 'effortLevel'
|
||||
| 'effortSettingsJson'
|
||||
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
|
||||
| 'appendSystemPrompt'
|
||||
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
|
||||
| 'codemanPrefixedSessionId'
|
||||
/**
|
||||
@@ -432,6 +434,39 @@ export interface CliCapabilities {
|
||||
* this is sized for the narrowest pane the CLI was measured at. Default 1.
|
||||
*/
|
||||
blockedLines?: number;
|
||||
/**
|
||||
* Source of a regex every non-blank row BELOW an idle composer must match (at most
|
||||
* three rows): the footer and hint rows this CLI draws there and nowhere else. It is
|
||||
* what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove
|
||||
* that the `promptGlyph` row they read is this CLI's own composer, at rest, and not
|
||||
* a dialog option or a transcript line the agent printed. A CLI that omits it never
|
||||
* takes guarded input: stage and submit refuse it. Tested one row at a time.
|
||||
*/
|
||||
composerFooterLine?: string;
|
||||
/**
|
||||
* Source of a regex matching a row of a suggestion popup this CLI draws directly
|
||||
* ABOVE its composer (a slash-command list, a file or mention search, its key-hint
|
||||
* row). The footer below such a composer is unchanged, so without this guarded
|
||||
* stage/submit would read the popup frame as an idle composer, and Enter would
|
||||
* accept the highlighted entry instead of sending the draft. Tested one row at a
|
||||
* time against the contiguous block just above the composer row, never further
|
||||
* up the pane: a match there refuses the frame. See `readGuardedComposer()`.
|
||||
*/
|
||||
composerPopupLine?: string;
|
||||
/**
|
||||
* Characters that, as the first character of a draft, make Enter run a command
|
||||
* rather than send a prompt (codex: `/` slash commands, `!` shell commands).
|
||||
* Guarded stage refuses such a message before typing; commands go through the
|
||||
* ordinary input route.
|
||||
*/
|
||||
composerCommandPrefixes?: string;
|
||||
/**
|
||||
* Characters that, starting the word under the caret, open a completion popup
|
||||
* whose highlighted entry Enter accepts (codex: `@` file and mention search, `$`
|
||||
* skills). Guarded stage refuses a message whose LAST word starts with one,
|
||||
* before typing: the caret ends there, and the popup follows the caret.
|
||||
*/
|
||||
composerCompletionSigils?: string;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
@@ -563,6 +598,30 @@ export interface CliCapabilities {
|
||||
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
|
||||
*/
|
||||
transcriptPageKeys?: boolean;
|
||||
/**
|
||||
* `'inline'`: the CLI renders inline (its content stacks at the bottom of the
|
||||
* main screen), so while a phone's on-screen keyboard is up the browser keeps
|
||||
* the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`,
|
||||
* terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through
|
||||
* `window.__codemanKeyboardHoldModes`, which the server builds from this field.
|
||||
*
|
||||
* Why it exists: every PTY height change makes Claude Code's inline renderer
|
||||
* clear the screen and reprint only the tail that fits (#672). Measured on
|
||||
* Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's
|
||||
* size printing 100 numbered lines: five keyboard open/close cycles (PTY
|
||||
* 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in
|
||||
* `tmux capture-pane` history, the other 25 gone for good; with the rows held
|
||||
* the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained.
|
||||
*
|
||||
* ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height
|
||||
* (codex: banner at the top, composer pinned to the bottom) cannot fit both
|
||||
* ends above the keyboard and must be resized instead, so it stays absent.
|
||||
* Absent means resize, the safe default for an unmeasured CLI. Even when set,
|
||||
* the hold stands aside while the CLI reports mouse tracking (Claude's
|
||||
* fullscreen mode, which lays out the full height and has not been measured
|
||||
* losing lines, so it resizes as before), and for remote and docker sessions.
|
||||
*/
|
||||
keyboardHold?: 'inline';
|
||||
keyboardAccessory: 'agent' | 'shell';
|
||||
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
|
||||
privilegedCommandGate: boolean;
|
||||
@@ -755,9 +814,11 @@ export interface CliCapabilities {
|
||||
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
|
||||
* provider and reports "No API key found for the selected model") or for
|
||||
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
|
||||
* declares). Absent = the config alone selects the model (claude's env vars,
|
||||
* opencode's blob, codex's top-level `model` key). Applied by the session's
|
||||
* respawn options through the entry's `legacyConfigField`, never by id.
|
||||
* declares), and codex carries its bare selected id (`{modelId}`, an id the
|
||||
* entry's `model` token pattern must be able to carry). Absent = the config
|
||||
* alone selects the model (claude's env vars, opencode's blob). Applied by
|
||||
* the session's respawn options through the entry's `legacyConfigField`,
|
||||
* never by id.
|
||||
*
|
||||
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
|
||||
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
|
||||
@@ -799,19 +860,13 @@ export interface CliCapabilities {
|
||||
* reuses that file.
|
||||
*
|
||||
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim.
|
||||
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
|
||||
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
|
||||
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
|
||||
* completions` with no `/v1` insertion of its own (its real public API base,
|
||||
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
|
||||
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
|
||||
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
|
||||
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
|
||||
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
|
||||
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
|
||||
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
|
||||
* no suffix — appending one there would be wrong, not just redundant.
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
|
||||
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
|
||||
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
|
||||
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
|
||||
* would be wrong, not just redundant. The per-session config-file recipes do not need
|
||||
* this flag at all: `renderConfigFile()` applies the suffix inside every template
|
||||
* (deepseek's patch included), so no configDir entry carries a raw base URL.
|
||||
*/
|
||||
customModelInjection:
|
||||
| {
|
||||
@@ -831,8 +886,40 @@ export interface CliCapabilities {
|
||||
kind: 'configDir';
|
||||
dirEnvVar: string;
|
||||
fileName: string;
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
|
||||
launchModel?: string;
|
||||
/**
|
||||
* Names of entries under this CLI's REAL home that must be symlinked into the
|
||||
* isolated directory after the generated file is written: the same trick
|
||||
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
|
||||
* home carries state that must survive the redirect (deepseek's `sessions` transcript
|
||||
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
|
||||
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
|
||||
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
|
||||
* in-session settings write turns `settings.yaml` into a local file.
|
||||
* Never include `fileName` itself: the generated file owns that name in the isolated
|
||||
* directory, and a link there would write THROUGH into the user's real file.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHome?: string[];
|
||||
/**
|
||||
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
|
||||
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
|
||||
* dsh does itself on a first turn) before linking: linking a nonexistent directory
|
||||
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
|
||||
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
|
||||
* links serve no purpose the CLI could not do itself). Must be a subset of
|
||||
* `linkFromRealHome`; entries absent from it are treated as files.
|
||||
* Consumed generically by custom-model-injection-apply.ts.
|
||||
*/
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/**
|
||||
* Fallback location of the CLI's real home, relative to the user's home directory
|
||||
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
|
||||
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
|
||||
* declared, so no consumer has to hardcode a per-CLI default of its own.
|
||||
*/
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
| { kind: 'unsupported' };
|
||||
}
|
||||
|
||||
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||
PATH_PROBE_STALL_CEILING,
|
||||
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||
);
|
||||
|
||||
/**
|
||||
* How long a session waits before trying again to resolve its workspace's symlinks
|
||||
* after the folder did not answer, so a mount that recovers is picked up (the pane
|
||||
* directory reading stays in the resolved spelling meanwhile).
|
||||
*/
|
||||
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
|
||||
@@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000;
|
||||
/** Pause between scheduled run iterations (ms) */
|
||||
export const ITERATION_PAUSE_MS = 2000;
|
||||
|
||||
// ============================================================================
|
||||
// Git Fetch Interval
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A
|
||||
* run lands within one tick of the interval the user set, because the interval is timed from the start of
|
||||
* a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing.
|
||||
*/
|
||||
export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Mux Stats
|
||||
// ============================================================================
|
||||
|
||||
@@ -52,18 +52,22 @@ export interface CustomModelHost {
|
||||
defaultModelId?: string;
|
||||
/**
|
||||
* Discovered context-window size (tokens) per model id, keyed by the same strings as
|
||||
* `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
|
||||
* llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models`
|
||||
* response has no such field. Only ever probed for a model the server already reports as
|
||||
* loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
|
||||
* probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
|
||||
* actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
|
||||
* has no entry for simply gets no context-length env override applied — never a guess.
|
||||
* `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest
|
||||
* source the server offers: the llama-swap launch command reported by `GET /running`
|
||||
* (the source of truth for a loaded model), then llama.cpp's `GET /props?model=<id>`
|
||||
* `n_ctx` (only ever probed for a model the server already reports as loaded, since
|
||||
* llama-swap treats `/props?model=` as a routing hint that can trigger an actual
|
||||
* (slow, GPU-swapping) model load as a side effect of merely asking), then the model's
|
||||
* own `GET /v1/models` entry (`context_length`, else `context_window`, which current
|
||||
* llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A
|
||||
* model with no entry here falls back to the endpoint's `defaultContextLength`; with
|
||||
* neither, it simply gets no context-length env override applied, never a guess.
|
||||
*/
|
||||
modelContextLengths?: Record<string, number>;
|
||||
/**
|
||||
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
|
||||
* and populated on the same loaded-models-only basis. Read from the launch command's
|
||||
* but populated only for models the server reports loaded (a listing never states a slot
|
||||
* count, unlike `contextLengths`, which every entry can supply). Read from the launch command's
|
||||
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
|
||||
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
|
||||
* the command cannot say). llama-server splits its context across these slots (by default
|
||||
@@ -88,6 +92,29 @@ export interface CustomModelHost {
|
||||
* server-side check relies on.
|
||||
*/
|
||||
modelSizesGB?: Record<string, number>;
|
||||
/**
|
||||
* User-set fallback context length (tokens), applied via
|
||||
* `resolveCustomModelContextLength()` at every read site (the swap route, the
|
||||
* quick-start route, boot recovery) only when `modelContextLengths` has no entry for
|
||||
* the selected model (a gateway that states no per-model figure, a model never loaded
|
||||
* yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor
|
||||
* check divides it by any stored slot count exactly like a discovered figure. Never
|
||||
* written by discovery, so a value set in the endpoint editor survives every
|
||||
* re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI
|
||||
* assume its own default.
|
||||
*/
|
||||
defaultContextLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context window to apply for `modelId` on `host`: the discovered per-model figure when
|
||||
* discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
|
||||
* (no override applied, never a guess). ONE helper for every read site: the swap route,
|
||||
* the quickstart route and boot recovery each re-write the CLI's config files, and a site
|
||||
* that forgot the fallback silently loses the endpoint default on the next respawn.
|
||||
*/
|
||||
export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined {
|
||||
return host.modelContextLengths?.[modelId] ?? host.defaultContextLength;
|
||||
}
|
||||
|
||||
export function customModelHostsPath(configDir: string): string {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* cli-registry changes" requirement it was written against.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
@@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string {
|
||||
* Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full
|
||||
* envOverrides object a caller should merge into the session/process env
|
||||
* (the dir-redirect var plus any `extraEnv` the config file references by
|
||||
* name). Never touches anything outside `baseDir` — the caller is
|
||||
* responsible for choosing an isolated directory (never the user's real
|
||||
* `~/.codex`, `~/.pi`, etc.).
|
||||
* name). The generated files stay under `baseDir`, but a recipe that declares
|
||||
* `linkFromRealHome` additionally links (and, for missing directory targets,
|
||||
* creates) entries in the CLI's REAL home: the caller is still responsible
|
||||
* for choosing an isolated `baseDir` (never the user's real `~/.codex`,
|
||||
* `~/.pi`, etc.), it is just no longer the only directory touched.
|
||||
*
|
||||
* pi and omp embed the API key literally in the file, so the tree is written
|
||||
* 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod
|
||||
* covers a re-apply onto a file that already exists (`mode` only applies at
|
||||
* creation).
|
||||
*/
|
||||
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> {
|
||||
export function applyConfigDirInjection(
|
||||
baseDir: string,
|
||||
injection: ConfigDirInjection,
|
||||
/**
|
||||
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
|
||||
* set one: a user who relocated the CLI's real home (an allowlisted key like
|
||||
* DSH_HOME) must have their state linked from THAT directory, not the server's.
|
||||
*/
|
||||
sessionHomeOverride?: string
|
||||
): Record<string, string> {
|
||||
for (const file of injection.files) {
|
||||
const filePath = join(baseDir, file.relPath);
|
||||
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
|
||||
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
|
||||
* generated config file and not the state that has to survive the session (deepseek:
|
||||
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
|
||||
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
|
||||
* this consumer is recipe-driven and names no CLI itself.
|
||||
*
|
||||
* Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
|
||||
* onto a session already holding a previous injection's value must not self-link), then
|
||||
* the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
|
||||
* are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
|
||||
* cwd, and joining it here would mkdir/symlink under the SERVER's working directory and
|
||||
* leave relative links that dangle. No home resolvable =
|
||||
* no links, same best-effort posture as `linkSharedProjectsDir()`.
|
||||
*/
|
||||
function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void {
|
||||
const names = injection.linkFromRealHome;
|
||||
if (!names || names.length === 0) return;
|
||||
const absoluteOnly = (value: string | undefined): string | undefined =>
|
||||
value && isAbsolute(value) ? value : undefined;
|
||||
const fromSession = sessionHomeOverride?.trim();
|
||||
const realHome =
|
||||
absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) ||
|
||||
absoluteOnly(process.env[injection.dirEnvVar]?.trim()) ||
|
||||
(injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined);
|
||||
if (!realHome) return;
|
||||
const dirs = new Set(injection.linkFromRealHomeDirs ?? []);
|
||||
for (const name of names) {
|
||||
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
|
||||
}
|
||||
}
|
||||
|
||||
function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
|
||||
// lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
|
||||
// re-applies on every restart, so the second pass would try to recreate the link and
|
||||
// throw EEXIST. lstat answers for real entries, good links and dangling links alike.
|
||||
try {
|
||||
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
|
||||
} catch {
|
||||
// ENOENT: nothing is here yet, the only state in which a link gets created
|
||||
}
|
||||
if (!existsSync(target)) {
|
||||
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
|
||||
// Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
|
||||
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
|
||||
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
|
||||
// what dsh itself does on a first turn.
|
||||
try {
|
||||
mkdirSync(target, { recursive: true, mode: 0o700 });
|
||||
} catch {
|
||||
return; // e.g. the target's parent refuses writes: no link rather than a dangling one
|
||||
}
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only: a platform that refuses symlinks keeps the documented
|
||||
// side effect for this session rather than failing the whole apply over it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real, shared Claude config directory Codeman's own host process runs under — honors
|
||||
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
|
||||
@@ -250,7 +324,13 @@ export function applyCustomModelInjection(
|
||||
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
|
||||
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
|
||||
*/
|
||||
workingDir?: string
|
||||
workingDir?: string,
|
||||
/**
|
||||
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
|
||||
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
|
||||
* home for `linkFromRealHome` from the session's value before the server's.
|
||||
*/
|
||||
sessionEnvLookup?: (envVar: string) => string | undefined
|
||||
): AppliedCustomModel | undefined {
|
||||
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
||||
if (injection.kind === 'unsupported') return undefined;
|
||||
@@ -283,6 +363,6 @@ export function applyCustomModelInjection(
|
||||
};
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
+104
-11
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
|
||||
* `customModelInjection.launchModel` template, never hand-built per CLI.
|
||||
*/
|
||||
launchModel?: string;
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
|
||||
linkFromRealHome?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
|
||||
linkFromRealHomeDirs?: string[];
|
||||
/** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
|
||||
realHomeFallback?: string;
|
||||
}
|
||||
|
||||
export interface UnsupportedInjection {
|
||||
@@ -136,14 +142,22 @@ export function buildCustomModelInjection(
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||
}
|
||||
|
||||
case 'configDir': {
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel(
|
||||
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||
{
|
||||
kind: 'configDir',
|
||||
dirEnvVar: cap.dirEnvVar,
|
||||
files: [{ relPath: cap.fileName, content }],
|
||||
extraEnv,
|
||||
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
|
||||
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
|
||||
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
|
||||
},
|
||||
cap.launchModel,
|
||||
modelId
|
||||
);
|
||||
@@ -164,41 +178,114 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size: opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
// `limit.context` is the FULL window as far as opencode is concerned: it reserves for
|
||||
// the reply itself, so the discovered total is passed through, never a shrunk figure.
|
||||
return { context: total, output };
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): string {
|
||||
switch (template) {
|
||||
case 'opencode-json':
|
||||
case 'opencode-json': {
|
||||
// `limit` carries the discovered context window (issue #749): without it opencode assumes
|
||||
// a large default window for an unrecognized model id and eventually overflows a much
|
||||
// smaller local server. `context` is the FULL window and opencode reserves for the reply
|
||||
// inside it itself, so `opencodeModelLimit` passes the real total through and only
|
||||
// `output` gets the capped reservation.
|
||||
const limit = opencodeModelLimit(contextLength);
|
||||
return JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
custom: {
|
||||
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||
models: { [modelId]: {} },
|
||||
models: { [modelId]: limit ? { limit } : {} },
|
||||
},
|
||||
},
|
||||
model: `custom/${modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
|
||||
* reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
|
||||
* name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
|
||||
* so an in-session `/model` switch back to `deepseek-official` (or a profile with web
|
||||
* search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
|
||||
* entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
|
||||
*/
|
||||
const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
|
||||
/** The `[model.<name>]` block name grok's config.toml uses for the injected model — also
|
||||
* what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */
|
||||
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
|
||||
|
||||
function renderConfigFile(
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): { content: string; extraEnv?: Record<string, string> } {
|
||||
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||
switch (template) {
|
||||
case 'deepseek-patch-json': {
|
||||
const route = { provider: 'codeman-custom', model: modelId };
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
// By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
|
||||
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
|
||||
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
|
||||
],
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
|
||||
// a `[model].default` table — codex rejects that with "invalid type: map, expected
|
||||
@@ -217,15 +304,21 @@ function renderConfigFile(
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? [
|
||||
`model_context_window = ${Math.trunc(contextLength)}`,
|
||||
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'[model_providers.custom]',
|
||||
`name = "Custom Endpoint"`,
|
||||
`base_url = ${quoted(baseUrl)}`,
|
||||
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
|
||||
`env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
|
||||
`wire_api = "responses"`,
|
||||
'',
|
||||
].join('\n');
|
||||
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
|
||||
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
|
||||
}
|
||||
case 'pi-models-json':
|
||||
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface WebLaunchOptions {
|
||||
basePath?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
allowUnauthenticatedTunnel?: boolean;
|
||||
multiuser?: boolean;
|
||||
}
|
||||
|
||||
@@ -92,6 +93,7 @@ export function buildWebArgs(options: WebLaunchOptions): string[] {
|
||||
if (options.basePath) args.push('--base-url', options.basePath);
|
||||
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
|
||||
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
|
||||
if (options.allowUnauthenticatedTunnel) args.push('--allow-unauthenticated-tunnel');
|
||||
if (options.multiuser) args.push('--multiuser');
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -26,13 +26,15 @@
|
||||
* **1. One zstd FRAME per append, not one zstd stream.** The file is
|
||||
* `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into
|
||||
* its own frame and writing it at the end. `zstd -dc` handles that (frames
|
||||
* concatenate by definition), but Node's `zlib.zstdDecompress()` and
|
||||
* `createZstdDecompress()` both stop at the first frame end: measured on a real
|
||||
* 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned
|
||||
* 43,747 bytes / 56 lines. That is a silent truncation to the session header —
|
||||
* every call would have reported "no answer yet" forever. `decodeZstdFrames()`
|
||||
* below walks the frame headers itself and decompresses each frame, and
|
||||
* `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures.
|
||||
* concatenate by definition), but before Node 24.22 and 26.10
|
||||
* (nodejs/node#65865) `zlib.zstdDecompress()` and `createZstdDecompress()` both
|
||||
* stop at the first frame end, and the supported range still includes those
|
||||
* versions: measured on a real 56-line transcript, Node returned 158 bytes /
|
||||
* 1 line where the CLI returned 43,747 bytes / 56 lines. That is a silent
|
||||
* truncation to the session header: every call would have reported "no answer
|
||||
* yet" forever. `decodeZstdFrames()` below walks the frame headers itself and
|
||||
* decompresses each frame, and `test/deepseek-transcript.test.ts` pins it
|
||||
* against multi-frame fixtures.
|
||||
*
|
||||
* **2. The user's prompts are mixed with injected context.** Every turn also
|
||||
* writes a `user/message` whose source is a plugin (the runtime-context
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise<DockerCase[]>
|
||||
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases,
|
||||
* but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases}
|
||||
* reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an
|
||||
* empty or half-written file, and to a caller deciding where git may run that must never look like "no
|
||||
* Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault).
|
||||
*/
|
||||
export async function readDockerCasesOrThrow(configDir: string): Promise<DockerCase[]> {
|
||||
const path = dockerCasesPath(configDir);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`);
|
||||
return parsed as DockerCase[];
|
||||
}
|
||||
|
||||
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
|
||||
* transcript and its subagent transcripts, each from a byte offset.
|
||||
*
|
||||
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
|
||||
* hooks report, so a session is read from wherever its claude really writes. Before the first
|
||||
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
|
||||
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
|
||||
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
|
||||
* guessed; it is found with the next hook. Subagents live in
|
||||
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
|
||||
*
|
||||
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
|
||||
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
|
||||
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
|
||||
* report their size with an empty chunk. A request with `len` is a block that must end at
|
||||
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
|
||||
* With `onlyRequested` ("older" reads) unrequested files are left out.
|
||||
*
|
||||
* Tests: `test/edit-history-local-source.test.ts`.
|
||||
*
|
||||
* @module edit-history-local-source
|
||||
*/
|
||||
|
||||
import { open, readdir, realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join } from 'node:path';
|
||||
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
|
||||
import { claudeProjectsDir } from './utils/claude-transcript.js';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
|
||||
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
|
||||
const META_MAX_BYTES = 8192;
|
||||
|
||||
/** One file to read from where the last read stopped (`len` only for an "older" block). */
|
||||
export interface EditSourceRequest {
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
relPath: string;
|
||||
offset: number;
|
||||
len?: number;
|
||||
}
|
||||
|
||||
export interface EditSourceFile {
|
||||
relPath: string;
|
||||
/** The file's size when it was read. */
|
||||
size: number;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
}
|
||||
|
||||
export interface EditSources {
|
||||
/** The conversation id: the main transcript's file name without `.jsonl`. */
|
||||
transcriptId: string;
|
||||
files: EditSourceFile[];
|
||||
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
|
||||
metas: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface EditSourceReadOptions {
|
||||
/** Read only the requested files; the others are not listed. */
|
||||
onlyRequested?: boolean;
|
||||
}
|
||||
|
||||
/** What a path turned out to be (for Bash image candidates). */
|
||||
export interface PathProbe {
|
||||
/** The path with symlinks resolved. */
|
||||
realPath: string;
|
||||
kind: 'file' | 'directory' | 'other';
|
||||
/** Size in bytes (0 for anything that is not a regular file). */
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
|
||||
if (len <= 0) return Buffer.alloc(0);
|
||||
const fh = await open(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, offset);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} catch {
|
||||
return Buffer.alloc(0);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSize(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? s.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
|
||||
export async function findClaudeTranscript(
|
||||
ids: readonly string[],
|
||||
projectsDir: string = claudeProjectsDir()
|
||||
): Promise<string | null> {
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const id of new Set(ids)) {
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const dir of dirs) {
|
||||
const path = join(projectsDir, dir, `${id}.jsonl`);
|
||||
try {
|
||||
const s = await stat(path);
|
||||
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
|
||||
} catch {
|
||||
// not in this project dir
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
|
||||
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
|
||||
* override; the server's config dir when unset). Null when neither finds one.
|
||||
*/
|
||||
export async function resolveClaudeTranscript(
|
||||
watcherPath: string | null | undefined,
|
||||
ids: readonly string[],
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
|
||||
* is not an absolute `<id>.jsonl` path or the file does not exist.
|
||||
*/
|
||||
export async function readClaudeEditSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
|
||||
const transcriptId = basename(mainPath, '.jsonl');
|
||||
if (!ID_PATTERN.test(transcriptId)) return null;
|
||||
|
||||
const byPath = new Map<string, EditSourceRequest>();
|
||||
for (const r of requests) {
|
||||
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
|
||||
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
|
||||
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
|
||||
byPath.set(r.relPath, r);
|
||||
}
|
||||
|
||||
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
|
||||
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
|
||||
try {
|
||||
for (const name of (await readdir(subDir)).sort()) {
|
||||
const m = AGENT_FILE_PATTERN.exec(name);
|
||||
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
|
||||
}
|
||||
} catch {
|
||||
// no subagents yet
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, Math.floor(budgetBytes));
|
||||
const files: EditSourceFile[] = [];
|
||||
const metas: Record<string, string> = {};
|
||||
for (const src of sources) {
|
||||
const size = await fileSize(src.path);
|
||||
if (size === null) {
|
||||
if (src.relPath === 'main') return null;
|
||||
continue;
|
||||
}
|
||||
const req = byPath.get(src.relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req?.len !== undefined) {
|
||||
const blockStart = Math.min(req.offset, size);
|
||||
const blockEnd = Math.min(req.offset + req.len, size);
|
||||
len = Math.min(blockEnd - blockStart, remaining);
|
||||
start = blockEnd - len;
|
||||
} else if (req) {
|
||||
start = Math.min(req.offset, size);
|
||||
len = Math.min(remaining, size - start);
|
||||
} else {
|
||||
len = Math.min(size, remaining);
|
||||
start = size - len;
|
||||
}
|
||||
const chunk = await readRange(src.path, start, len);
|
||||
remaining -= chunk.length;
|
||||
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
|
||||
if (!req && src.agentId) {
|
||||
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
|
||||
const metaSize = await fileSize(metaPath);
|
||||
if (metaSize !== null) {
|
||||
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { transcriptId, files, metas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
|
||||
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
|
||||
*
|
||||
* The paths come from Bash output, so the agent chose them: each one first goes through the
|
||||
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
|
||||
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
|
||||
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
|
||||
* Sequential, so one sync holds at most one threadpool worker.
|
||||
*/
|
||||
export async function probeLocalPaths(
|
||||
paths: readonly string[],
|
||||
home: string = homedir(),
|
||||
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
|
||||
): Promise<Array<PathProbe | null>> {
|
||||
const out: Array<PathProbe | null> = [];
|
||||
for (const p of paths) {
|
||||
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
||||
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
|
||||
out.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const real = await realpath(path);
|
||||
const s = await stat(real);
|
||||
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
|
||||
} catch {
|
||||
out.push(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
|
||||
* edited (with their diff) and the images its Bash calls may have produced.
|
||||
*
|
||||
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
|
||||
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
|
||||
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
|
||||
*
|
||||
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
|
||||
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
|
||||
* start and end time. Whether a candidate file was actually produced by the call (its
|
||||
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
|
||||
* the remote host: this module does no IO.
|
||||
*
|
||||
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
|
||||
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
|
||||
* into the next call.
|
||||
*
|
||||
* Tests: `test/edit-history-parse.test.ts`.
|
||||
*
|
||||
* @module edit-history-parse
|
||||
*/
|
||||
|
||||
import { posix } from 'node:path';
|
||||
|
||||
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
|
||||
export const MAX_HUNK_BYTES = 64 * 1024;
|
||||
/** Image paths kept per Bash call. */
|
||||
export const MAX_IMAGE_CANDIDATES = 20;
|
||||
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
|
||||
export const MAX_PENDING_BASH = 200;
|
||||
/**
|
||||
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
|
||||
* this bounds it; an image path past this point of a command is still found in the output.
|
||||
*/
|
||||
export const MAX_PENDING_COMMAND_CHARS = 8192;
|
||||
/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
|
||||
export const MAX_PENDING_FIELD_CHARS = 4096;
|
||||
|
||||
export type EditKind = 'code' | 'image';
|
||||
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
|
||||
export type EditTool = 'Edit' | 'Write' | 'edit';
|
||||
|
||||
export interface EditHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface EditRecord {
|
||||
/** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
|
||||
id: string;
|
||||
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
|
||||
ts: number;
|
||||
path: string;
|
||||
kind: EditKind;
|
||||
tool: EditTool;
|
||||
/** A `Write` that created the file. */
|
||||
created: boolean;
|
||||
/** Counted over the whole patch, also when `hunks` was cut. */
|
||||
added: number;
|
||||
removed: number;
|
||||
hunks: EditHunk[];
|
||||
truncated: boolean;
|
||||
/** Set for lines of a subagent transcript. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PendingBash {
|
||||
id: string;
|
||||
ts: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface ImageCandidate {
|
||||
toolUseId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
paths: string[];
|
||||
agentId?: string;
|
||||
/**
|
||||
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
|
||||
* applies; the file must still exist, and a version already listed is not listed again.
|
||||
*/
|
||||
sent?: boolean;
|
||||
}
|
||||
|
||||
export interface EditParseResult {
|
||||
records: EditRecord[];
|
||||
candidates: ImageCandidate[];
|
||||
pending: PendingBash[];
|
||||
}
|
||||
|
||||
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
|
||||
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
|
||||
// After the extension: no further name character, and no `.ext` (a.png.bak).
|
||||
const EXT_END = '(?![\\w-])(?!\\.\\w)';
|
||||
// Characters that end an unquoted path token.
|
||||
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
|
||||
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
|
||||
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
|
||||
// timestamped name (`plot-12:30.png`) is still found.
|
||||
`file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 2./3. Quoted paths, spaces allowed.
|
||||
`"([^"\\n]*\\.${IMAGE_EXT})"`,
|
||||
`'([^'\\n]*\\.${IMAGE_EXT})'`,
|
||||
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
|
||||
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
|
||||
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
|
||||
// some characters let a match start at every other character of a long token
|
||||
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
|
||||
// length. The name before the extension must not be empty.
|
||||
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
].join('|'),
|
||||
'gi'
|
||||
);
|
||||
|
||||
/** `image` for the image extensions the panel can thumbnail, else `code`. */
|
||||
export function classifyEditPath(path: string): EditKind {
|
||||
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-looking paths in a Bash call's command and output, in order of appearance,
|
||||
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
|
||||
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
|
||||
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
|
||||
* skipped.
|
||||
*/
|
||||
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
|
||||
const text = [command, stdout, stderr].join('\n');
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of text.matchAll(PATH_TOKEN_RE)) {
|
||||
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
|
||||
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
|
||||
let resolved: string;
|
||||
if (raw.startsWith('/')) resolved = posix.normalize(raw);
|
||||
else if (raw.startsWith('~/')) resolved = raw;
|
||||
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
|
||||
else continue;
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
out.push(resolved);
|
||||
if (out.length >= MAX_IMAGE_CANDIDATES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
|
||||
* (`truncated`). Also applied to hunks loaded from a store file.
|
||||
*/
|
||||
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
|
||||
const hunks: EditHunk[] = [];
|
||||
let budget = MAX_HUNK_BYTES;
|
||||
let truncated = false;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
if (truncated) break;
|
||||
const kept: string[] = [];
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
const cost = Buffer.byteLength(l) + 3;
|
||||
if (cost > budget) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
budget -= cost;
|
||||
kept.push(l);
|
||||
}
|
||||
hunks.push({
|
||||
oldStart: Number(h.oldStart) || 0,
|
||||
oldLines: Number(h.oldLines) || 0,
|
||||
newStart: Number(h.newStart) || 0,
|
||||
newLines: Number(h.newLines) || 0,
|
||||
lines: kept,
|
||||
});
|
||||
}
|
||||
return { hunks, truncated };
|
||||
}
|
||||
|
||||
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of patch) {
|
||||
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
|
||||
for (const l of h.lines) {
|
||||
if (typeof l !== 'string') continue;
|
||||
if (l.startsWith('+')) added++;
|
||||
else if (l.startsWith('-')) removed++;
|
||||
}
|
||||
}
|
||||
return { ...capHunks(patch), added, removed };
|
||||
}
|
||||
|
||||
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
|
||||
function createdFileHunk(content: string): Record<string, unknown> {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
|
||||
}
|
||||
|
||||
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
|
||||
if (!isRecord(result)) return null;
|
||||
const { filePath, structuredPatch } = result;
|
||||
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
|
||||
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
|
||||
const kind = classifyEditPath(filePath);
|
||||
// A Write that created a file reports an empty patch; its diff is the whole content.
|
||||
const patch =
|
||||
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
|
||||
? [createdFileHunk(result.content)]
|
||||
: structuredPatch;
|
||||
const { hunks, added, removed, truncated } = toHunks(patch);
|
||||
const record: EditRecord = {
|
||||
id,
|
||||
ts,
|
||||
path: filePath,
|
||||
kind,
|
||||
tool,
|
||||
created: result.type === 'create',
|
||||
added,
|
||||
removed,
|
||||
hunks,
|
||||
truncated,
|
||||
};
|
||||
if (agentId) record.agentId = agentId;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
|
||||
* files claude delivered to the user, which is how a session shows generated images.
|
||||
*/
|
||||
function sentImagePaths(result: unknown): string[] {
|
||||
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
|
||||
const out: string[] = [];
|
||||
for (const a of result.attachments) {
|
||||
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
|
||||
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
|
||||
function blockText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse transcript lines into edit records and Bash image candidates.
|
||||
*
|
||||
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
|
||||
* inside it) and anything else that does not parse is skipped.
|
||||
* @param pending unanswered Bash calls from the previous chunk.
|
||||
*/
|
||||
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
|
||||
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
|
||||
const records: EditRecord[] = [];
|
||||
const candidates: ImageCandidate[] = [];
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(rec)) continue;
|
||||
const message = rec.message;
|
||||
const content = isRecord(message) ? message.content : undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
|
||||
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
|
||||
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
|
||||
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
|
||||
|
||||
if (rec.type === 'assistant') {
|
||||
for (const block of content) {
|
||||
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
|
||||
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
|
||||
const command = block.input.command;
|
||||
if (typeof command !== 'string') continue;
|
||||
if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
|
||||
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) entry.agentId = agentId;
|
||||
open.delete(block.id);
|
||||
open.set(block.id, entry);
|
||||
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rec.type !== 'user') continue;
|
||||
const results = content.filter(
|
||||
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
|
||||
);
|
||||
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
|
||||
const single = results.length === 1;
|
||||
for (const block of results) {
|
||||
const id = block.tool_use_id as string;
|
||||
if (single) {
|
||||
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
|
||||
if (edit) records.push(edit);
|
||||
}
|
||||
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
|
||||
if (sent.length > 0) {
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
|
||||
if (agentId) candidate.agentId = agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
const call = open.get(id);
|
||||
if (!call) continue;
|
||||
open.delete(id);
|
||||
const r = single ? rec.toolUseResult : undefined;
|
||||
let stdout: string;
|
||||
let stderr = '';
|
||||
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
|
||||
stdout = typeof r.stdout === 'string' ? r.stdout : '';
|
||||
stderr = typeof r.stderr === 'string' ? r.stderr : '';
|
||||
} else {
|
||||
stdout = blockText(block.content);
|
||||
}
|
||||
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
|
||||
if (paths.length === 0) continue;
|
||||
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
|
||||
if (call.agentId) candidate.agentId = call.agentId;
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return { records, candidates, pending: [...open.values()] };
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
|
||||
* session, plus where in each transcript file reading stopped.
|
||||
*
|
||||
* Sources are the main transcript (`main`) and each subagent transcript
|
||||
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
|
||||
* - `offset`: the next byte to read forward; always a line boundary.
|
||||
* - `floor`: the lowest byte already parsed; > 0 while older lines are unread
|
||||
* ("ältere laden" reads `[floor − budget, floor)`, see `olderRange`).
|
||||
* The reader (`edit-history-local-source.ts`) reads forward from each `offset` of the
|
||||
* current conversation, a file without one from `budget` bytes before its end, and hands the
|
||||
* bytes to `apply`. Cuts happen only at `\n`, which never occurs inside a UTF-8 sequence, so
|
||||
* multibyte characters split by a chunk boundary are never decoded half.
|
||||
*
|
||||
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
|
||||
* drops the cut first line and leaves `floor` there. A line longer than the budget is
|
||||
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
|
||||
* (`floorMidLine`); the line after it (forward) or before it (backward) is read whole.
|
||||
* That needs every chunk to be a full budget unless it reaches the file's end (forward) or
|
||||
* start (older): a chunk with no line end then means one line longer than the budget. The
|
||||
* caller does not hand over a chunk that a budget shared by several files cut short.
|
||||
*
|
||||
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
|
||||
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
|
||||
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
|
||||
* the file holds one cursor per transcript file of the current conversation, at most
|
||||
* {@link MAX_PENDING_BASH} unanswered Bash calls and at most {@link MAX_AGENTS} subagent labels.
|
||||
*
|
||||
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
|
||||
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
|
||||
* deleted with the session (`remove()`; `sweep()` at startup removes files no session owns),
|
||||
* never fed to `/api/search`, and refused by the attachment, folder and download routes through
|
||||
* `isSensitivePath` (`web/sensitive-path.ts`). `load()` takes the file as untrusted: past
|
||||
* {@link MAX_FILE_BYTES} it is not parsed, and every part is rebuilt under the same bounds.
|
||||
*
|
||||
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
|
||||
*
|
||||
* Tests: `test/edit-history-store.test.ts`.
|
||||
*
|
||||
* @module edit-history-store
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
capHunks,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
type EditRecord,
|
||||
type EditTool,
|
||||
type ImageCandidate,
|
||||
type PendingBash,
|
||||
} from './edit-history-parse.js';
|
||||
|
||||
export const MAX_ENTRIES = 500;
|
||||
/** Diff text (and paths) kept per session; see {@link entryBytes}. */
|
||||
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
/** Longest string kept from a subagent's `meta.json` field. */
|
||||
export const MAX_AGENT_META_CHARS = 1024;
|
||||
/**
|
||||
* Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
|
||||
* {@link MAX_ENTRIES}, so besides the newest label there always is one.
|
||||
*/
|
||||
export const MAX_AGENTS = MAX_ENTRIES + 1;
|
||||
/** Bytes read per file and pass (first read, forward step, "older" step). */
|
||||
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
|
||||
/**
|
||||
* A store file larger than this is not parsed (starts empty). One written within the caps
|
||||
* above stays far below it; JSON escaping at most doubles ordinary text.
|
||||
*/
|
||||
export const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||
export const MAIN_SOURCE = 'main';
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
const FILE_VERSION = 1;
|
||||
const NL = 0x0a;
|
||||
/** A transcript id as the reader takes it (`<id>.jsonl`): no path separators. */
|
||||
const TRANSCRIPT_ID = /^[A-Za-z0-9._-]+$/;
|
||||
/**
|
||||
* Names this process's tmp files (`<id>.json.<tag>.tmp`); any other tmp file is left over from
|
||||
* an earlier run (`sweep`), even one whose pid this process has again (a container's pid 1).
|
||||
*/
|
||||
export const TMP_TAG = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
|
||||
/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
|
||||
export type EditHistoryTool = EditTool | 'bash' | 'sent';
|
||||
|
||||
export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> {
|
||||
tool: EditHistoryTool;
|
||||
}
|
||||
|
||||
export interface SourceCursor {
|
||||
offset: number;
|
||||
floor: number;
|
||||
/** The next forward read starts inside a line longer than the budget; skip to its end. */
|
||||
midLine?: boolean;
|
||||
/** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
|
||||
floorMidLine?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMeta {
|
||||
description: string;
|
||||
agentType: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
}
|
||||
|
||||
export interface ByteRange {
|
||||
offset: number;
|
||||
len: number;
|
||||
}
|
||||
|
||||
export interface ApplyInput {
|
||||
transcriptId: string;
|
||||
/** `main` or `subagents/agent-<id>.jsonl`. */
|
||||
source: string;
|
||||
startOffset: number;
|
||||
chunk: Buffer;
|
||||
/** Size of the file when the chunk was read. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
added: number;
|
||||
candidates: ImageCandidate[];
|
||||
}
|
||||
|
||||
export interface ConfirmedImage {
|
||||
toolUseId: string;
|
||||
path: string;
|
||||
ts: number;
|
||||
agentId?: string;
|
||||
tool?: 'bash' | 'sent';
|
||||
}
|
||||
|
||||
export interface EditHistoryStoreOptions {
|
||||
/** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */
|
||||
dir?: string;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface StoreFile {
|
||||
version: number;
|
||||
sessionId: string;
|
||||
transcriptId: string | null;
|
||||
cursors: Record<string, SourceCursor>;
|
||||
pendingBash: PendingBash[];
|
||||
agents: Record<string, AgentMeta>;
|
||||
entries: EditHistoryEntry[];
|
||||
}
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** What an entry costs against {@link MAX_STORE_BYTES}: its strings and diff lines, plus a fixed overhead. */
|
||||
export function entryBytes(e: EditHistoryEntry): number {
|
||||
let n = 128 + Buffer.byteLength(e.id) + Buffer.byteLength(e.path) + Buffer.byteLength(e.agentId ?? '');
|
||||
for (const h of e.hunks) for (const l of h.lines) n += Buffer.byteLength(l) + 3;
|
||||
return n;
|
||||
}
|
||||
|
||||
function metaString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
|
||||
}
|
||||
|
||||
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
|
||||
export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
try {
|
||||
return agentMetaFrom(JSON.parse(text));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOffset(v: unknown): v is number {
|
||||
return Number.isSafeInteger(v) && (v as number) >= 0;
|
||||
}
|
||||
|
||||
// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
|
||||
// loses the broken parts instead of failing every request or stalling a cursor.
|
||||
|
||||
function agentMetaFrom(raw: unknown): AgentMeta | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const meta: AgentMeta = {
|
||||
description: metaString(raw.description) ?? '',
|
||||
agentType: metaString(raw.agentType) ?? '',
|
||||
};
|
||||
const worktreePath = metaString(raw.worktreePath);
|
||||
const worktreeBranch = metaString(raw.worktreeBranch);
|
||||
if (worktreePath) meta.worktreePath = worktreePath;
|
||||
if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
|
||||
return meta;
|
||||
}
|
||||
|
||||
function cursorFrom(raw: unknown): SourceCursor | null {
|
||||
if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
|
||||
const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
|
||||
if (raw.midLine === true) cursor.midLine = true;
|
||||
if (raw.floorMidLine === true) cursor.floorMidLine = true;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
const loadedField = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_PENDING_FIELD_CHARS;
|
||||
|
||||
function pendingBashFrom(raw: unknown): PendingBash | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const { id, ts, command, cwd, agentId } = raw;
|
||||
if (!loadedField(id) || !Number.isFinite(ts) || typeof command !== 'string' || !loadedField(cwd)) return null;
|
||||
if (agentId !== undefined && !loadedField(agentId)) return null;
|
||||
const pending: PendingBash = { id, ts: ts as number, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (agentId) pending.agentId = agentId;
|
||||
return pending;
|
||||
}
|
||||
|
||||
const ENTRY_KINDS = new Set(['code', 'image']);
|
||||
const ENTRY_TOOLS = new Set(['Edit', 'Write', 'edit', 'bash', 'sent']);
|
||||
const countFrom = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : 0);
|
||||
|
||||
/**
|
||||
* A loaded entry rebuilt from its known fields: unknown fields and non-string diff lines are
|
||||
* dropped and the per-edit {@link MAX_HUNK_BYTES} is applied again; null when it is unusable.
|
||||
*/
|
||||
function entryFrom(raw: unknown): EditHistoryEntry | null {
|
||||
if (!isObj(raw) || typeof raw.id !== 'string' || !raw.id || typeof raw.path !== 'string' || !raw.path) return null;
|
||||
if (!Number.isFinite(raw.ts) || !ENTRY_KINDS.has(raw.kind as string) || !ENTRY_TOOLS.has(raw.tool as string)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(raw.hunks)) return null;
|
||||
const { hunks, truncated } = capHunks(raw.hunks);
|
||||
const entry: EditHistoryEntry = {
|
||||
id: raw.id,
|
||||
ts: raw.ts as number,
|
||||
path: raw.path,
|
||||
kind: raw.kind as EditHistoryEntry['kind'],
|
||||
tool: raw.tool as EditHistoryTool,
|
||||
created: raw.created === true,
|
||||
added: countFrom(raw.added),
|
||||
removed: countFrom(raw.removed),
|
||||
hunks,
|
||||
truncated: truncated || raw.truncated === true,
|
||||
};
|
||||
if (typeof raw.agentId === 'string' && raw.agentId) entry.agentId = raw.agentId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export class EditHistoryStore {
|
||||
readonly sessionId: string;
|
||||
private readonly file: string;
|
||||
private readonly debounceMs: number;
|
||||
private transcriptId: string | null = null;
|
||||
private cursors = new Map<string, SourceCursor>();
|
||||
private pendingBash: PendingBash[] = [];
|
||||
private agents = new Map<string, AgentMeta>();
|
||||
private entries = new Map<string, EditHistoryEntry>();
|
||||
/** Sum of {@link entryBytes} over `entries`. */
|
||||
private bytes = 0;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
/** The last queued write; `remove()` waits for it. */
|
||||
private writing: Promise<void> | null = null;
|
||||
private removed = false;
|
||||
|
||||
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
|
||||
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store with the persisted state of `sessionId`; empty when the file is missing,
|
||||
* unreadable or larger than {@link MAX_FILE_BYTES}.
|
||||
*/
|
||||
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
|
||||
const store = new EditHistoryStore(sessionId, opts);
|
||||
let data: unknown;
|
||||
try {
|
||||
if ((await stat(store.file)).size > MAX_FILE_BYTES) return store;
|
||||
data = JSON.parse(await readFile(store.file, 'utf-8'));
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
if (!isObj(data)) return store;
|
||||
if (data.version !== FILE_VERSION) return store;
|
||||
const f = data as unknown as StoreFile;
|
||||
// The sync builds the previous transcript's path from it (`/clear`), so only a plain id counts.
|
||||
store.transcriptId =
|
||||
typeof f.transcriptId === 'string' && TRANSCRIPT_ID.test(f.transcriptId) ? f.transcriptId : null;
|
||||
// Cursors are offsets into that conversation's files; without it they mean nothing.
|
||||
const cursors = store.transcriptId !== null && isObj(f.cursors) ? f.cursors : {};
|
||||
for (const [k, raw] of Object.entries(cursors)) {
|
||||
const cursor = cursorFrom(raw);
|
||||
if (cursor) store.cursors.set(k, cursor);
|
||||
}
|
||||
if (Array.isArray(f.pendingBash)) {
|
||||
store.pendingBash = f.pendingBash
|
||||
.map(pendingBashFrom)
|
||||
.filter((p): p is PendingBash => p !== null)
|
||||
.slice(-MAX_PENDING_BASH);
|
||||
}
|
||||
if (Array.isArray(f.entries)) {
|
||||
store.addRecords(f.entries.map(entryFrom).filter((e): e is EditHistoryEntry => e !== null));
|
||||
}
|
||||
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
|
||||
const meta = k.length <= MAX_AGENT_META_CHARS ? agentMetaFrom(raw) : null;
|
||||
if (meta) store.putAgent(k, meta);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of every session `keep` rejects, and tmp files other processes
|
||||
* left behind (startup: a crash between removing a session's record and deleting its file,
|
||||
* or mid-write; an older build).
|
||||
* Returns how many sessions' files were deleted.
|
||||
*/
|
||||
static async sweep(
|
||||
keep: (sessionId: string) => boolean,
|
||||
opts: Pick<EditHistoryStoreOptions, 'dir'> = {}
|
||||
): Promise<number> {
|
||||
const dir = opts.dir ?? dataPath('edit-history');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
const tmp = /\.json\.[\w-]+\.tmp$/.test(name);
|
||||
const stale = tmp
|
||||
? !name.endsWith(`.json.${TMP_TAG}.tmp`)
|
||||
: name.endsWith('.json') && !keep(name.slice(0, -'.json'.length));
|
||||
if (!stale) continue;
|
||||
await unlink(join(dir, name)).catch(() => undefined);
|
||||
if (!tmp) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Delete the stored history of `sessionId` without loading it. */
|
||||
static async delete(sessionId: string, opts: Pick<EditHistoryStoreOptions, 'dir'> = {}): Promise<void> {
|
||||
await new EditHistoryStore(sessionId, opts).remove();
|
||||
}
|
||||
|
||||
getTranscriptId(): string | null {
|
||||
return this.transcriptId;
|
||||
}
|
||||
|
||||
getCursor(source: string): SourceCursor | undefined {
|
||||
const c = this.cursors.get(source);
|
||||
return c ? { ...c } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any source has unread older lines that could still be shown. At a cap (no
|
||||
* room left for another entry) an older edit would be dropped the moment it is read, so
|
||||
* there is nothing more to load.
|
||||
*/
|
||||
get backfillTruncated(): boolean {
|
||||
if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
|
||||
for (const c of this.cursors.values()) if (c.floor > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Entries newest first. */
|
||||
list(): EditHistoryEntry[] {
|
||||
return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
|
||||
}
|
||||
|
||||
get(id: string): EditHistoryEntry | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
getAgents(): Record<string, AgentMeta> {
|
||||
return Object.fromEntries(this.agents);
|
||||
}
|
||||
|
||||
hasAgentMeta(agentId: string): boolean {
|
||||
return this.agents.has(agentId);
|
||||
}
|
||||
|
||||
/** Record a subagent's `meta.json`; ignored when it does not parse. */
|
||||
setAgentMeta(agentId: string, metaText: string): void {
|
||||
const meta = this.removed ? null : parseAgentMeta(metaText);
|
||||
if (!meta) return;
|
||||
this.putAgent(agentId, meta);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
|
||||
olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
|
||||
const cursor = this.cursors.get(source);
|
||||
if (!cursor || cursor.floor <= 0) return null;
|
||||
const offset = Math.max(0, cursor.floor - budget);
|
||||
return { offset, len: cursor.floor - offset };
|
||||
}
|
||||
|
||||
/** Every source with a cursor in the current conversation. */
|
||||
knownSources(): string[] {
|
||||
return [...this.cursors.keys()];
|
||||
}
|
||||
|
||||
/** Sources with older lines left. */
|
||||
sourcesWithOlder(): string[] {
|
||||
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
|
||||
}
|
||||
|
||||
/**
|
||||
* A source whose file is gone (a deleted subagent transcript): its cursor goes, so it no
|
||||
* longer offers older lines. Its entries stay.
|
||||
*/
|
||||
dropSource(source: string): void {
|
||||
if (this.removed || !this.cursors.delete(source)) return;
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a forward chunk: read from the source's cursor, or, without one, its last `budget`
|
||||
* bytes; a full budget unless it reaches the file's end. A file that shrank below its cursor
|
||||
* (replaced) loses the cursor and is read from its end next time. Returns new entries and
|
||||
* Bash image candidates.
|
||||
*/
|
||||
apply(input: ApplyInput): ApplyResult {
|
||||
if (this.removed) return { added: 0, candidates: [] };
|
||||
const { transcriptId, source, startOffset, chunk, size } = input;
|
||||
if (transcriptId !== this.transcriptId) {
|
||||
this.transcriptId = transcriptId;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
}
|
||||
let cursor = this.cursors.get(source);
|
||||
if (cursor && size < cursor.offset) {
|
||||
this.cursors.delete(source);
|
||||
cursor = undefined;
|
||||
}
|
||||
if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
|
||||
|
||||
const atEof = startOffset + chunk.length >= size;
|
||||
// Bytes before `from` belong to a line that started before this chunk.
|
||||
let from = 0;
|
||||
if (cursor ? cursor.midLine : startOffset > 0) {
|
||||
const nl = chunk.indexOf(NL);
|
||||
if (nl === -1) {
|
||||
// Still inside that line: move past it unless it is the line being written. A first
|
||||
// read began inside it, so the floor does too.
|
||||
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl < from) {
|
||||
if (from > 0) {
|
||||
// The skipped (or cut first) line ended here; the next one is read from its start.
|
||||
this.setCursor(source, cursor, startOffset + from, startOffset + from, false);
|
||||
} else if (!atEof && chunk.length > 0) {
|
||||
// One line longer than the budget: skip it instead of re-reading it forever.
|
||||
this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
|
||||
} else if (!cursor) {
|
||||
this.setCursor(source, undefined, startOffset, startOffset, false);
|
||||
}
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const end = lastNl + 1;
|
||||
this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
|
||||
this.pendingBash = parsed.pending;
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a chunk read at `olderRange`: a full budget unless it reaches the file's start. It
|
||||
* ends at the floor, which is a line start, or with `floorMidLine` lies inside a line longer
|
||||
* than the budget (its part in this chunk is dropped).
|
||||
*/
|
||||
applyOlder(input: ApplyInput): ApplyResult {
|
||||
const { transcriptId, source, startOffset, chunk } = input;
|
||||
const cursor = this.removed ? undefined : this.cursors.get(source);
|
||||
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const none = { added: 0, candidates: [] };
|
||||
let end = chunk.length;
|
||||
if (cursor.floorMidLine) {
|
||||
const lastNl = chunk.lastIndexOf(NL);
|
||||
if (lastNl === -1 && startOffset > 0) {
|
||||
// Still inside the long line.
|
||||
cursor.floor = startOffset;
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
// The long line starts at `end`: drop its part.
|
||||
end = lastNl + 1;
|
||||
delete cursor.floorMidLine;
|
||||
}
|
||||
let from = 0;
|
||||
if (startOffset > 0) {
|
||||
// The newline at `end - 1` closes a line that may have started before this chunk.
|
||||
const nl = chunk.subarray(0, end).indexOf(NL);
|
||||
if (nl === -1 || nl === end - 1) {
|
||||
if (end < chunk.length) {
|
||||
// It ends where the long line starts: the next block, ending there, holds it whole.
|
||||
cursor.floor = startOffset + end;
|
||||
} else {
|
||||
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
|
||||
cursor.floor = startOffset;
|
||||
cursor.floorMidLine = true;
|
||||
}
|
||||
this.scheduleSave();
|
||||
return none;
|
||||
}
|
||||
from = nl + 1;
|
||||
}
|
||||
cursor.floor = startOffset + from;
|
||||
// Calls whose results lie in already-read newer lines cannot be paired here.
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/** Add Bash images whose mtime was confirmed inside their call's window. */
|
||||
addImageEntries(images: readonly ConfirmedImage[]): number {
|
||||
if (this.removed) return 0;
|
||||
const records: EditHistoryEntry[] = images.map((img) => {
|
||||
const entry: EditHistoryEntry = {
|
||||
id: `${img.toolUseId}:${img.path}`,
|
||||
ts: img.ts,
|
||||
path: img.path,
|
||||
kind: 'image',
|
||||
tool: img.tool ?? 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
};
|
||||
if (img.agentId) entry.agentId = img.agentId;
|
||||
return entry;
|
||||
});
|
||||
const added = this.addRecords(records);
|
||||
if (added) this.scheduleSave();
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write pending changes, if any, and wait for a write under way (the store is dropped from
|
||||
* memory and loaded again later). Unlike `flush()`, a store without changes writes nothing.
|
||||
*/
|
||||
async settle(): Promise<void> {
|
||||
if (this.saveTimer) return this.flush();
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
/** Write now (cancels a pending debounced write). Writes run one after another. */
|
||||
async flush(): Promise<void> {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
const run = (this.writing ?? Promise.resolve()).then(() => this.write());
|
||||
this.writing = run.catch(() => undefined);
|
||||
await run;
|
||||
}
|
||||
|
||||
/** True once {@link remove} ran: the store ignores every later write. */
|
||||
isRemoved(): boolean {
|
||||
return this.removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the persisted file (session deleted) and forget the content. The store takes no
|
||||
* changes afterwards, so a read still in flight neither writes nor refills it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
this.removed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
this.entries.clear();
|
||||
this.bytes = 0;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
this.agents.clear();
|
||||
// A write already under way ends first (or sees `removed`), so it cannot rename its
|
||||
// tmp file over the deleted store afterwards.
|
||||
await this.writing;
|
||||
await unlink(this.file).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
if (this.removed) return;
|
||||
const data: StoreFile = {
|
||||
version: FILE_VERSION,
|
||||
sessionId: this.sessionId,
|
||||
transcriptId: this.transcriptId,
|
||||
cursors: Object.fromEntries(this.cursors),
|
||||
pendingBash: this.pendingBash,
|
||||
agents: Object.fromEntries(this.agents),
|
||||
entries: this.list(),
|
||||
};
|
||||
const dir = join(this.file, '..');
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
// `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
|
||||
// makes it the store, so an existing store never keeps wider bits.
|
||||
const tmp = `${this.file}.${TMP_TAG}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
||||
if (this.removed) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
private putAgent(agentId: string, meta: AgentMeta): void {
|
||||
this.agents.delete(agentId);
|
||||
this.agents.set(agentId, meta);
|
||||
if (this.agents.size <= MAX_AGENTS) return;
|
||||
const used = new Set<string>();
|
||||
for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
|
||||
for (const id of this.agents.keys()) {
|
||||
if (this.agents.size <= MAX_AGENTS) break;
|
||||
// The newest label has no entries yet: they come with its transcript.
|
||||
if (id !== agentId && !used.has(id)) this.agents.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private setCursor(
|
||||
source: string,
|
||||
cursor: SourceCursor | undefined,
|
||||
offset: number,
|
||||
firstFloor: number,
|
||||
midLine: boolean,
|
||||
firstFloorMidLine = false
|
||||
): void {
|
||||
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
|
||||
if (midLine) next.midLine = true;
|
||||
if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
|
||||
this.cursors.set(source, next);
|
||||
}
|
||||
|
||||
private addRecords(records: readonly EditHistoryEntry[]): number {
|
||||
const fresh: string[] = [];
|
||||
for (const r of records) {
|
||||
if (this.entries.has(r.id)) continue;
|
||||
this.entries.set(r.id, r);
|
||||
this.bytes += entryBytes(r);
|
||||
fresh.push(r.id);
|
||||
}
|
||||
if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
|
||||
for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
|
||||
if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
|
||||
this.entries.delete(e.id);
|
||||
this.bytes -= entryBytes(e);
|
||||
}
|
||||
}
|
||||
// Only what is still there counts: an "older" block past the cap adds nothing.
|
||||
return fresh.filter((id) => this.entries.has(id)).length;
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.removed || this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.flush().catch((err) => {
|
||||
console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
this.saveTimer.unref?.();
|
||||
}
|
||||
}
|
||||
+90
-15
@@ -50,9 +50,10 @@
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rename, rm } from 'node:fs/promises';
|
||||
import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,8 +189,13 @@ export interface GitFailure {
|
||||
export interface CloneOptions {
|
||||
/** Pre-validated operand from `parseGitRepositoryUrl`. */
|
||||
repository: string;
|
||||
/** Absolute destination directory. Must NOT exist; created by git. */
|
||||
/** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
|
||||
destination: string;
|
||||
/**
|
||||
* Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user
|
||||
* picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused.
|
||||
*/
|
||||
intoEmptyDir?: boolean;
|
||||
/** Optional branch or tag (`--branch <ref> --single-branch`). */
|
||||
ref?: string;
|
||||
/** `--depth 1`: history-less but much faster on large repos. */
|
||||
@@ -590,6 +596,11 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
// First: git's refusal quotes the destination path, so a folder named openssl, rustls or "not found" would
|
||||
// otherwise match the host and repository patterns below, and a wrong code here decides a deletion.
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
if (timedOut) {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
@@ -625,12 +636,13 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
|
||||
if (
|
||||
/could not resolve host|connection refused|connection timed out|network is unreachable|\bssl(?:\b|_)|\btls\b|certificate|handshake/.test(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
|
||||
}
|
||||
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
||||
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
||||
}
|
||||
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
|
||||
}
|
||||
|
||||
@@ -701,6 +713,8 @@ interface GitRun {
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
timedOut: boolean;
|
||||
/** False when git never ran (queue full, slot wait timed out, spawn failed): nothing was written. */
|
||||
started: boolean;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
@@ -720,10 +734,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
const queuedAt = Date.now();
|
||||
const slot = await acquireGitSlot(timeoutMs);
|
||||
if (slot === 'queue-full') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
spawnError: 'EBUSY: git operation queue is full',
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
if (slot === 'timed-out') {
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true };
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true, started: false };
|
||||
}
|
||||
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
|
||||
try {
|
||||
@@ -736,7 +757,7 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
|
||||
resolve({ stdout: '', stderr: '', code: null, timedOut: false, started: false, spawnError: String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,8 +804,10 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
|
||||
child.on('error', (err) =>
|
||||
finish({ stdout, stderr, code: null, timedOut, started: false, spawnError: String(err) })
|
||||
);
|
||||
child.on('close', (code) => finish({ stdout, stderr, code, timedOut, started: true }));
|
||||
});
|
||||
} finally {
|
||||
releaseGitSlot();
|
||||
@@ -847,6 +870,35 @@ export async function probeGitRemote(
|
||||
};
|
||||
}
|
||||
|
||||
/** Destinations with a direct (`intoEmptyDir`) clone in flight. */
|
||||
const directClones = new Set<string>();
|
||||
|
||||
function destinationExists(): CloneResult {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
}
|
||||
|
||||
/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */
|
||||
async function isEmptyDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await lstat(path);
|
||||
return st.isDirectory() && (await readdir(path)).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove everything inside `path`, keeping `path` itself. Best effort. */
|
||||
async function emptyDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* nothing to clean, or it vanished */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `repository` into `destination`.
|
||||
*
|
||||
@@ -872,10 +924,33 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
|
||||
};
|
||||
}
|
||||
if (existsSync(opts.destination)) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
||||
};
|
||||
// One direct clone per folder at a time: once two gits have written into the same folder their
|
||||
// files cannot be told apart, so the second caller is refused before it spawns anything. Taken
|
||||
// before the emptiness check (which can precede a long wait for a git slot).
|
||||
if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists();
|
||||
directClones.add(opts.destination);
|
||||
try {
|
||||
if (!(await isEmptyDirectory(opts.destination))) return destinationExists();
|
||||
// The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
|
||||
const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
|
||||
if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
|
||||
const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError);
|
||||
// The folder was empty when this attempt took it, so what is in it is this attempt's partial tree, but
|
||||
// only if git actually ran: a refused folder, a full queue or a slot wait that timed out wrote nothing,
|
||||
// and anything in the folder by then (files the user copied in, another case created there) is not ours.
|
||||
// A mount that stopped answering is left alone too, so a libuv worker is not held on it.
|
||||
// git's own refusal is tested on its text, not on the classifier's code, so the deletion never depends on
|
||||
// which pattern matched first.
|
||||
const refused = /already exists and is not an empty directory/i.test(direct.stderr);
|
||||
if (direct.started && !refused && failure.code !== 'DESTINATION_EXISTS') {
|
||||
if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') {
|
||||
await emptyDirectory(opts.destination);
|
||||
}
|
||||
}
|
||||
return { ok: false, failure };
|
||||
} finally {
|
||||
directClones.delete(opts.destination);
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling of the destination (same filesystem), so the rename is atomic.
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview Git fetch on an interval: the sessions the user turned on, fetched by a tick the server owns
|
||||
* (`src/git-fetch-scheduler.ts`, wired in `src/web/server.ts`).
|
||||
*
|
||||
* The fetch module (src/git-fetch.ts) is the only git command the Git panel runs that writes, and on a timer
|
||||
* it is the only one that can run without being asked (cloning and worktree management write too, but those
|
||||
* happen when the user asks for them). Which is why the whole feature is off until the user turns it on, in
|
||||
* Settings → Bottom bar, per session in Session Options → Git.
|
||||
*
|
||||
* The scheduler holds no timers of its own. The server ticks it (`GIT_FETCH_TICK_INTERVAL_MS`) and every
|
||||
* tick reads the app setting FRESH, so a live toggle takes effect on the next tick instead of at boot, and
|
||||
* turning the feature off actually stops the fetching. A session can override both the switch and the
|
||||
* interval; a session with no override follows the app.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - Nothing runs when nothing is on: no repository set is read and nothing is spawned, so a quiet
|
||||
* install costs one settings read per tick. A session that turned itself on still runs even while the
|
||||
* app-wide switch is off: that is what a per-session switch has to mean.
|
||||
* - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker
|
||||
* workspace roots are checked again every tick, so a session whose case became a Docker case stops
|
||||
* fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches
|
||||
* nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a
|
||||
* container can write to), the folders it picked stay due for the next tick, and it is logged once
|
||||
* until a read succeeds again.
|
||||
* - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one
|
||||
* session: two sessions open on the same folder are the same clone, so they share one run and both get
|
||||
* its result. State is keyed by `resolve(workingDir)`, deliberately not `realpath`, because `realpath`
|
||||
* is itself one of the unbounded fs calls rule #516 bounds; the bounded probe below is the fs read.
|
||||
* Every session open on a folder is remembered, so closing the one holding the override does not stop
|
||||
* the folder's fetching while another session is still on it. A folder is due when ANY session open on it
|
||||
* is due, and the tick decides that per folder in a second pass: the order the sessions were read cannot
|
||||
* change which interval wins, so a 30 s session on a folder a 3600 s session last fetched ten minutes ago
|
||||
* is fetched now. The folder is probed once, not once per session on it.
|
||||
* - Before anything is fetched, the folder is probed BOUNDED with the default options (rule #516): a case
|
||||
* on a mount that stopped answering is skipped, never touched, and a probe that answers `unknown` is not
|
||||
* read as "not a folder".
|
||||
* - A schedule is stamped at SELECTION, when a session goes into `due`, not when its run starts, so a
|
||||
* tick that is still running cannot pick the same folder again; and a tick is skipped while the previous
|
||||
* one is still going, so the bound below is the bound for the whole tick, not per tick.
|
||||
* - At most `FETCH_CONCURRENCY` git processes at a time across the whole tick, because each is a git
|
||||
* process: ONE bound is shared by every run of the tick, so `FETCH_CONCURRENCY` is the tick's ceiling
|
||||
* rather than each folder's own. The folders get their OWN bound (`mapLimited`), because one semaphore
|
||||
* cannot serve both levels: a folder that holds a slot and then waits for one for its own fetch is
|
||||
* waiting on itself, and the folders that fill the bound are exactly the ones blocking it.
|
||||
* - The interval is timed from the START of a run, so a run that takes three minutes does not push the
|
||||
* next one out by three minutes; it lands on the tick after its own interval elapses.
|
||||
* - The per-repository limit and the git timeout stay the module's defaults (`MAX_REPOS`,
|
||||
* `DEFAULT_GIT_TIMEOUT_MS`): both settings are per device and never reach the server, so the server
|
||||
* cannot know what a device chose.
|
||||
* - A failure is reported, not retried harder: the folder keeps its schedule and gets its next run at its
|
||||
* own interval. A settings read that fails skips this tick only, rather than deciding from defaults.
|
||||
* settings.json is replaced atomically, so such a failure is a file that does not parse (a hand edit) or
|
||||
* a real read error, which lasts until someone fixes it: it is logged once, and again only after a read
|
||||
* has succeeded in between, not every 15 s tick.
|
||||
*
|
||||
* @module git-fetch-scheduler
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import {
|
||||
FETCH_CONCURRENCY,
|
||||
MAX_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS,
|
||||
gitFetchStatusFrom,
|
||||
type GitFetchOptions,
|
||||
type GitFetchRun,
|
||||
type GitFetchSettings,
|
||||
type GitFetchStatus,
|
||||
type GitFetchTrigger,
|
||||
} from './git-fetch.js';
|
||||
import { clampInt, DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS, createLimiter, mapLimited } from './git-workspace-status.js';
|
||||
import type { PathProbeKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** What the scheduler needs from a session: the facts a decision needs, and nothing else. */
|
||||
export interface GitFetchSessionView {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
|
||||
remote?: unknown;
|
||||
docker?: unknown;
|
||||
gitFetchEnabled?: boolean;
|
||||
gitFetchIntervalSeconds?: number;
|
||||
}
|
||||
|
||||
/** A session open on a folder, with the interval that makes IT due. One folder's sessions are a group. */
|
||||
interface GroupMember {
|
||||
view: GitFetchSessionView;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface GitFetchPorts {
|
||||
sessionIds(): Iterable<string>;
|
||||
getSession(id: string): GitFetchSessionView | undefined;
|
||||
/** Read FRESH every tick; that is the whole point of the tick-driven shape. */
|
||||
readSettings(): Promise<GitFetchSettings>;
|
||||
/**
|
||||
* Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the
|
||||
* tick then fetches nothing and the folders it picked are due again on the next tick.
|
||||
*/
|
||||
dockerWorkspaces(): Promise<string[]>;
|
||||
/**
|
||||
* Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called
|
||||
* with `pastCap`. Anything that is not `directory` (including `unknown`, which is never `absent`) is
|
||||
* skipped, not fetched.
|
||||
*/
|
||||
probePath(path: string): Promise<PathProbeKind>;
|
||||
/** Called through `fetchWorkspaceOnce`, so a run already going for this folder is joined, not stacked. */
|
||||
fetch(cwd: string, opts: GitFetchOptions, trigger: GitFetchTrigger): Promise<GitFetchRun>;
|
||||
publish(id: string, status: GitFetchStatus): void;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one session will actually do: its own override wins, otherwise the app-wide setting. The interval is
|
||||
* clamped again here because a persisted value can predate the schema's bounds.
|
||||
*/
|
||||
export function effectiveGitFetch(
|
||||
view: GitFetchSessionView,
|
||||
settings: GitFetchSettings
|
||||
): { enabled: boolean; intervalSeconds: number } {
|
||||
return {
|
||||
enabled: view.gitFetchEnabled ?? settings.enabled,
|
||||
intervalSeconds: clampInt(
|
||||
view.gitFetchIntervalSeconds ?? settings.intervalSeconds,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MAX_GIT_FETCH_INTERVAL_SECONDS,
|
||||
MIN_GIT_FETCH_INTERVAL_SECONDS
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class GitFetchScheduler {
|
||||
private readonly ports: GitFetchPorts;
|
||||
/** When each FOLDER last started a run; timed from the start, not the end. */
|
||||
private readonly lastRunAt = new Map<string, number>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** Session → folder, so `remove()` can keep the folder's schedule while other sessions are still on it. */
|
||||
private readonly folderOf = new Map<string, string>();
|
||||
private running = false;
|
||||
private stopped = false;
|
||||
/** The last settings read failed and was logged; cleared by the next read that succeeds. */
|
||||
private settingsUnreadable = false;
|
||||
/** The same for the Docker case list. */
|
||||
private dockerCasesUnreadable = false;
|
||||
|
||||
constructor(ports: GitFetchPorts) {
|
||||
this.ports = ports;
|
||||
}
|
||||
|
||||
/** One tick: fetch every folder whose sessions are on and due. Returns how many folders ran. */
|
||||
async tick(now = Date.now()): Promise<number> {
|
||||
if (this.stopped || this.running) return 0;
|
||||
this.running = true;
|
||||
try {
|
||||
let settings: GitFetchSettings;
|
||||
try {
|
||||
settings = await this.ports.readSettings();
|
||||
} catch (err) {
|
||||
// Writes are atomic, so this is a real read error or a file that does not parse (a hand edit), and it
|
||||
// stays that way until it is fixed: logged once, not on every tick. The reader never puts the file's
|
||||
// text in its error (readGitFetchSettings), since settings.json can hold an API key.
|
||||
if (!this.settingsUnreadable) {
|
||||
this.settingsUnreadable = true;
|
||||
this.ports.log?.(
|
||||
`git fetch: settings unreadable, ticks skipped until it reads again: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
this.settingsUnreadable = false;
|
||||
|
||||
// Pass 1: who is on what, before anything is decided. Every session open on a folder is recorded here,
|
||||
// not only the one that would get picked, so `remove()` can still see who is open on it when the session
|
||||
// holding the override is the one that closes.
|
||||
const groups = new Map<string, GroupMember[]>();
|
||||
for (const id of this.ports.sessionIds()) {
|
||||
const view = this.ports.getSession(id);
|
||||
if (!view || view.remote || view.docker) continue;
|
||||
const effective = effectiveGitFetch(view, settings);
|
||||
if (!effective.enabled) continue;
|
||||
const folder = resolve(view.workingDir);
|
||||
this.folderOf.set(id, folder);
|
||||
const group = groups.get(folder);
|
||||
const member = { view, intervalSeconds: effective.intervalSeconds };
|
||||
if (group) group.push(member);
|
||||
else groups.set(folder, [member]);
|
||||
}
|
||||
|
||||
// Pass 2: the folder's turn, decided from the whole group rather than from whichever session happened to
|
||||
// be read first. A folder is due when ANY session open on it is due: a session on a 30 s interval on a
|
||||
// folder a 3600 s session last fetched ten minutes ago has to be fetched now, because that same run
|
||||
// satisfies the long interval too. Deciding in one pass means the session read first owns the clock: a
|
||||
// group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the
|
||||
// run belongs to the folder, so no session in the group should.
|
||||
const due = new Map<string, GroupMember[]>();
|
||||
// What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching
|
||||
// nothing (an unreadable Docker case list, below) can leave the folders due again.
|
||||
const stampedOver = new Map<string, number | undefined>();
|
||||
for (const [folder, group] of groups) {
|
||||
if (this.inFlight.has(folder)) continue;
|
||||
const started = this.lastRunAt.get(folder);
|
||||
const isDue = started === undefined || group.some((m) => now - started >= m.intervalSeconds * 1000);
|
||||
if (!isDue) continue;
|
||||
// Bounded probe (rule #516), once per folder rather than once per session on it.
|
||||
const kind = await this.ports.probePath(folder);
|
||||
if (kind !== 'directory') {
|
||||
this.ports.log?.(`git fetch: ${folder} skipped, answered ${kind}`);
|
||||
continue;
|
||||
}
|
||||
this.inFlight.add(folder);
|
||||
// The clock starts at the selection, so a slow folder does not shift its own schedule.
|
||||
stampedOver.set(folder, started);
|
||||
this.lastRunAt.set(folder, now);
|
||||
due.set(folder, group);
|
||||
}
|
||||
if (!due.size) return 0;
|
||||
|
||||
// Only now, because nothing above needs it: a quiet tick does not read the Docker case list.
|
||||
let dockerWorkspaces: string[];
|
||||
try {
|
||||
dockerWorkspaces = await this.ports.dockerWorkspaces();
|
||||
} catch (err) {
|
||||
// Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a
|
||||
// container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared
|
||||
// stays cleared) and the folders are due again on the next tick instead of after their interval.
|
||||
for (const [folder, started] of stampedOver) {
|
||||
this.inFlight.delete(folder);
|
||||
if (this.lastRunAt.get(folder) !== now) continue;
|
||||
if (started === undefined) this.lastRunAt.delete(folder);
|
||||
else this.lastRunAt.set(folder, started);
|
||||
}
|
||||
if (!this.dockerCasesUnreadable) {
|
||||
this.dockerCasesUnreadable = true;
|
||||
this.ports.log?.(
|
||||
`git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
this.dockerCasesUnreadable = false;
|
||||
// ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own:
|
||||
// each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per
|
||||
// run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once.
|
||||
const fetches = createLimiter(FETCH_CONCURRENCY);
|
||||
// The folders get their OWN bound, because the same semaphore cannot serve both levels: a folder that
|
||||
// holds a slot and then waits for a slot for its own fetch is waiting on itself: once
|
||||
// `FETCH_CONCURRENCY` folders fill the bound, the ones holding slots are exactly the ones that can
|
||||
// never get one back. Both bounds are `FETCH_CONCURRENCY`, so the tick still never runs more git
|
||||
// processes than the module's ceiling.
|
||||
await mapLimited([...due.entries()], FETCH_CONCURRENCY, async ([folder, group]) => {
|
||||
try {
|
||||
const run = await this.ports.fetch(
|
||||
folder,
|
||||
{
|
||||
dockerWorkspaces,
|
||||
maxRepos: MAX_REPOS,
|
||||
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
||||
limiter: fetches,
|
||||
},
|
||||
'interval'
|
||||
);
|
||||
// Every session on the folder gets the SAME run, with the trigger of the run that actually happened:
|
||||
// if this tick joined a run a click had already started, the row has to say it was a click, because
|
||||
// that is what fetched it.
|
||||
for (const member of group) this.ports.publish(member.view.id, gitFetchStatusFrom(run.report, run.trigger));
|
||||
} catch (err) {
|
||||
// The run that failed keeps its reason in the panel's own row; here only the folder-level failure is
|
||||
// worth a line, because nothing else would say a scheduled fetch could not run at all.
|
||||
this.ports.log?.(`git fetch: ${folder} could not run: ${getErrorMessage(err)}`);
|
||||
} finally {
|
||||
this.inFlight.delete(folder);
|
||||
}
|
||||
});
|
||||
return due.size;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A session that closed or was detached: its schedule goes with it, but the FOLDER's stays while another
|
||||
* session is still open on it: the run belongs to the clone, not to the session that asked for it.
|
||||
*/
|
||||
remove(id: string): void {
|
||||
const folder = this.folderOf.get(id);
|
||||
this.folderOf.delete(id);
|
||||
if (!folder) return;
|
||||
const stillOpen = [...this.folderOf.values()].includes(folder);
|
||||
if (!stillOpen) {
|
||||
this.lastRunAt.delete(folder);
|
||||
this.inFlight.delete(folder);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.lastRunAt.clear();
|
||||
this.inFlight.clear();
|
||||
this.folderOf.clear();
|
||||
}
|
||||
|
||||
/** Folders the scheduler still remembers a run for, so a test can see a `remove()` actually took. */
|
||||
tracked(): number {
|
||||
return new Set([...this.lastRunAt.keys(), ...this.inFlight]).size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* @fileoverview `git fetch` for the repositories the Git panel shows, on the user's click
|
||||
* (`POST /api/sessions/:id/git-fetch`) or on an interval the user turned on
|
||||
* (`src/git-fetch-scheduler.ts`).
|
||||
*
|
||||
* The status module (src/git-workspace-status.ts) is deliberately offline: "behind" is as of the last
|
||||
* fetch, so a number that is stale can only be made current by fetching. This is the only git command the
|
||||
* Git panel runs that writes, and the only git command Codeman runs on a timer. Cloning a repository and
|
||||
* managing worktrees write too, but those are things the user asked for at the moment they asked; this is
|
||||
* the one that can happen later, by itself. Nothing here commits, checks out, merges or touches the working
|
||||
* tree: `git fetch` updates remote-tracking
|
||||
* refs and nothing else, and `FETCH_CONFIG_ARGS` keeps it from starting a gc in a repository an agent is
|
||||
* already using. It is not harmless in the way a read is: a fetch holds git's ref lock, so an agent's own
|
||||
* `git pull` in the same clone can collide with it (`cannot lock ref`), which is why one folder is fetched
|
||||
* by at most one run at a time (`fetchWorkspaceOnce`).
|
||||
*
|
||||
* Same split as git-clone.ts: the pure half (`buildFetchArgs`, `describeFetchFailure`, `fetchEnvFor`,
|
||||
* `gitFetchSettingsFrom`, `gitFetchStatusFrom`) holds every decision and tests without spawning, and the IO
|
||||
* half runs one bounded `git fetch --quiet` per repository.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - The repository set is `resolveWorkspaceRepos`, the single source the panel and the diff route
|
||||
* already use, so Fetch cannot reach a repository the panel would not show. The Docker rule is
|
||||
* checked before any git runs: a repository a container can write to is refused outright, because a
|
||||
* planted config would run on the host, and `git fetch` is exactly the command that can run a
|
||||
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the automatic maintenance
|
||||
* that follows a fetch) and its config commands such as `credential.helper`, which is why a timer only
|
||||
* runs it after the user opts in.
|
||||
* - One run per folder at a time: `fetchWorkspaceOnce` joins a run already going for the same folder
|
||||
* instead of starting a second one, because two fetches in one clone collide on the ref lock.
|
||||
* - Bounded: the git timeout from Settings → Bottom bar per repository, `MAX_REPOS_LIMIT` repositories at
|
||||
* most, a hard wall-clock bound on each spawn, and `FETCH_CONCURRENCY` git processes at a time for the
|
||||
* whole run: one `Limiter`, shared by the scheduler's tick, not one bound per folder.
|
||||
* - Non-interactive: `GIT_TERMINAL_PROMPT=0` and BatchMode ssh, so a private repository that needs a
|
||||
* login fails fast instead of hanging the request on an invisible username prompt. Codeman collects
|
||||
* no credentials; a repository the user's own git is already signed in to works because that is the
|
||||
* user's existing setup, so unlike a clone no credential helper is disabled here. That BatchMode ssh is
|
||||
* a default, never an override: `fetchEnvFor` keeps whatever the repository's `core.sshCommand`, the
|
||||
* user's own `GIT_SSH_COMMAND` or a `GIT_SSH` already says, because on a machine with several ssh setups
|
||||
* that choice is usually the one that works. ⚠️ The default still leaves a record: `StrictHostKeyChecking=accept-new`
|
||||
* writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a
|
||||
* write.
|
||||
* - The timeout kills the PROCESS GROUP, not just the `git` parent. A fetch over http spawns
|
||||
* `git-remote-http` children, and `execFile`'s `timeout` signals the parent only, so the answer can go
|
||||
* out while a helper is still running, and on a timer that repeats every tick with nobody watching.
|
||||
* `spawn` with `detached: true` puts the tree in its own group so the escalation (SIGTERM, then SIGKILL
|
||||
* `FETCH_KILL_ESCALATION_MS` later) reaches the children too, which is why the negative pid is used
|
||||
* rather than `child.kill()`. Same shape as `runGit` in git-clone.ts, without that module's slot queue:
|
||||
* fetch is bounded by `FETCH_CONCURRENCY`, not by a clone queue.
|
||||
* - Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`),
|
||||
* and the browser writes it with `textContent` only.
|
||||
*
|
||||
* @module git-fetch
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { basename, relative, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { classifyGitFailure, gitNonInteractiveEnv, sanitizeGitOutput, type GitFailureCode } from './git-clone.js';
|
||||
import {
|
||||
createLimiter,
|
||||
DEFAULT_GIT_TIMEOUT_MS,
|
||||
type Limiter,
|
||||
resolveOverviewLimits,
|
||||
resolveWorkspaceRepos,
|
||||
type GitRunner,
|
||||
} from './git-workspace-status.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Git processes in flight at once, for one run, and for a whole scheduler tick, because the tick hands ONE
|
||||
* bound to every run (see `createLimiter`): each fetch is one git process, and a folder can hold up to
|
||||
* `MAX_REPOS_LIMIT`. The tick bounds its folders separately, because the same semaphore cannot serve both
|
||||
* levels: a folder holding a slot would then wait for a slot its own fetch needs.
|
||||
*/
|
||||
export const FETCH_CONCURRENCY = 4;
|
||||
/** A fetch on a busy remote can print a lot; a bound on what we will hold. */
|
||||
const MAX_FETCH_OUTPUT_BYTES = 2 * 1024 * 1024;
|
||||
/** git's stderr is kept as a bounded tail (as in git-clone.ts): enough for the reason, not the whole stream. */
|
||||
const MAX_FETCH_STDERR_BYTES = 8_192;
|
||||
/** A `git config --get` answer is one line; this is a sanity cap, not an expected size. */
|
||||
const MAX_SSH_COMMAND_BYTES = 4_096;
|
||||
/**
|
||||
* How long the process group gets to answer SIGTERM before the same escalation goes to SIGKILL. Kept as a
|
||||
* const so a test can say how long it is willing to wait for the group to actually die.
|
||||
*/
|
||||
export const FETCH_KILL_ESCALATION_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Config overrides set ahead of the argv, so an unattended fetch cannot do more than move refs: no
|
||||
* fsmonitor hook, and no automatic maintenance or gc. A fetch is the git command that can run a
|
||||
* repository's own hooks (`reference-transaction`, and `pre-auto-gc` through the maintenance that follows a
|
||||
* fetch), and a gc started by a timer in a repository an agent is working in is exactly the kind of extra
|
||||
* write this module must not do. Kept as a const so a test can pin it without spawning git.
|
||||
*/
|
||||
export const FETCH_CONFIG_ARGS = ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
|
||||
|
||||
/** The interval the user can turn on (Settings → Bottom bar, and Session Options → Git): bounds and default. */
|
||||
export const DEFAULT_GIT_FETCH_INTERVAL_SECONDS = 300;
|
||||
export const MIN_GIT_FETCH_INTERVAL_SECONDS = 30;
|
||||
export const MAX_GIT_FETCH_INTERVAL_SECONDS = 3600;
|
||||
|
||||
export interface GitFetchSettings {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings object as the scheduler should read it: absent means OFF, because nothing runs on a timer
|
||||
* until the user turns this on, and a number outside the schema's bounds is clamped rather than trusted.
|
||||
* Pure, so the reading itself is testable without a settings file.
|
||||
*/
|
||||
export function gitFetchSettingsFrom(settings: Record<string, unknown> | undefined): GitFetchSettings {
|
||||
const raw = settings?.gitFetchIntervalSeconds;
|
||||
const seconds =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_GIT_FETCH_INTERVAL_SECONDS;
|
||||
return {
|
||||
enabled: settings?.gitFetchEnabled === true,
|
||||
intervalSeconds: Math.min(MAX_GIT_FETCH_INTERVAL_SECONDS, Math.max(MIN_GIT_FETCH_INTERVAL_SECONDS, seconds)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The only git command this module runs. Kept as a function so a test can pin the verb: nothing here
|
||||
* commits, checks out, merges or prunes, and no operand is interpolated: the repository is the process
|
||||
* `cwd` and the argv is fixed.
|
||||
*/
|
||||
export function buildFetchArgs(): string[] {
|
||||
return ['fetch', '--quiet'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `git` process also gets the `FETCH_CONFIG_ARGS` overrides ahead of this argv; they are kept separate
|
||||
* so pinning the verb here does not have to say anything about maintenance, and so both can be tested
|
||||
* without spawning.
|
||||
*
|
||||
* A repository with no remote is a fact, not a fault. Git's wordings for it differ by version (2.47
|
||||
* exits quietly and this code is never reached, older git says "no default remote repository"), so the
|
||||
* branch is pinned on literal fixtures and the code the panel shows is the code this writes.
|
||||
*/
|
||||
export type FetchFailureCode = GitFailureCode | 'NO_REMOTE';
|
||||
|
||||
export interface FetchFailure {
|
||||
code: FetchFailureCode;
|
||||
/** User-facing summary. */
|
||||
message: string;
|
||||
/** Tail of git's own stderr, control-stripped and credential-redacted. */
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thrown git error as the panel should show it. `classifyGitFailure` (git-clone.ts) already knows the
|
||||
* network reasons (auth, an unreachable host, a missing git), so they are reused here; only its timeout
|
||||
* wording is written for clones, so the fetch path replaces it with what the user can actually do.
|
||||
*/
|
||||
export function describeFetchFailure(err: unknown): FetchFailure {
|
||||
const e = err as { code?: unknown; stderr?: unknown; message?: string; killed?: boolean };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||
const spawnError = typeof e.message === 'string' ? e.message : undefined;
|
||||
const clean = sanitizeGitOutput(stderr);
|
||||
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
|
||||
if (/no default remote repository|no remote configured|nothing to fetch/.test(lower)) {
|
||||
return {
|
||||
code: 'NO_REMOTE',
|
||||
message: 'This repository has no remote, so there is nothing to fetch.',
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
const timedOut = e.code === 'ETIMEDOUT' || e.killed === true;
|
||||
const failure = classifyGitFailure(stderr, timedOut, spawnError);
|
||||
if (failure.code === 'TIMEOUT')
|
||||
failure.message = 'Git timed out. Raise “Git status: git timeout” in Settings → Bottom bar, or fetch from a shell.';
|
||||
return failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment a fetch gets, as a pure decision so it can be pinned without spawning git.
|
||||
*
|
||||
* `gitNonInteractiveEnv` supplies the BatchMode default, and the default is only a default: git reads
|
||||
* `GIT_SSH_COMMAND` from the environment ahead of the repository's `core.sshCommand`, so setting it would
|
||||
* override a repository that already chose its own ssh (an alternative host, an identity file, a jump box)
|
||||
* and break a setup that works today. The server's own `GIT_SSH` is the same kind of answer: git reads it
|
||||
* after both of those (`GIT_SSH_COMMAND`, then `core.sshCommand`, then `GIT_SSH`), so a written default would
|
||||
* hide it too. That is the only case where this module makes an ssh choice on the user's behalf, so it is the
|
||||
* whole decision here: the caller supplies the value it read, and this decides between the user's
|
||||
* environment and the repository's own answer.
|
||||
*/
|
||||
export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | null): NodeJS.ProcessEnv {
|
||||
const env = gitNonInteractiveEnv(base);
|
||||
// `gitNonInteractiveEnv` only writes the default when the environment had nothing, so this is exactly the
|
||||
// case where it was a choice rather than a pass-through, and the repository's own answer, or a `GIT_SSH` in
|
||||
// the environment, wins over it.
|
||||
if (!base.GIT_SSH_COMMAND && (repoSshCommand || base.GIT_SSH)) delete env.GIT_SSH_COMMAND;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository's own `core.sshCommand`, or null. One bounded, read-only `git config --get` per fetch:
|
||||
* git would honour that config itself if we did not write `GIT_SSH_COMMAND` over it, so honouring it means
|
||||
* not writing one. `--get` reads local and global config, which is the value git would have used, and no
|
||||
* hook or filter runs on the way to it.
|
||||
*
|
||||
* The read happens inside the same slot and the same wall-clock bound as the fetch it serves, so it cannot
|
||||
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
|
||||
*/
|
||||
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: MAX_SSH_COMMAND_BYTES,
|
||||
env: gitNonInteractiveEnv(),
|
||||
});
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
// A repository with no such setting, or a read that could not finish: the default is what git would have
|
||||
// used anyway, and a fetch must not fail because the check for a config value failed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch is allowed to write refs, so this runner does not carry `--no-optional-locks`: fetch takes a
|
||||
* required lock anyway.
|
||||
*
|
||||
* `spawn`, not `execFile`, because the bound has to reach the whole tree (see the fileoverview): the
|
||||
* caller is answered at the timeout, and the SIGTERM → SIGKILL escalation is deliberately NOT cancelled
|
||||
* when the answer goes out, because the promise is a promise about the answer while the group still has to
|
||||
* die. That is the difference from `runGit` in git-clone.ts, which disarms the escalation at `finish` and so
|
||||
* can leave a `git-remote-http` child running after a timed-out call.
|
||||
*
|
||||
* The thrown error keeps the shape `describeFetchFailure` reads: `stderr` for the reason, `message` for a
|
||||
* spawn failure (`spawn git ENOENT` → GIT_MISSING), `killed` and `code` for the timeout. A non-zero exit
|
||||
* rejects the way `execFile` would (`code` is then the exit code), because resolving it would report a real
|
||||
* failure as a clean run with an empty reason.
|
||||
*
|
||||
* `timeoutMs` bounds the `core.sshCommand` read and the fetch TOGETHER: the spawn is armed with what the read
|
||||
* left, so a `git` that never answers costs one timeout, not two. A read that used the whole bound answers
|
||||
* as a timeout without starting the fetch.
|
||||
*/
|
||||
export const runFetch: GitRunner = async (cwd, args, opts) => {
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
||||
if (remainingMs <= 0) {
|
||||
throw Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
|
||||
stderr: '',
|
||||
killed: true,
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', [...FETCH_CONFIG_ARGS, ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutBytes = 0;
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const killTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (spawnError?: string, exitCode?: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Only the timeout timer is disarmed here. On a timeout the escalation is what the caller is waiting
|
||||
// for, and cancelling it is the bug this runner exists to fix.
|
||||
clearTimeout(timeoutTimer);
|
||||
reject(
|
||||
Object.assign(new Error(spawnError ?? `Command failed: git ${args.join(' ')}`), {
|
||||
stderr,
|
||||
killed: timedOut,
|
||||
code: timedOut ? 'ETIMEDOUT' : (exitCode ?? undefined),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), FETCH_KILL_ESCALATION_MS);
|
||||
fail();
|
||||
}, remainingMs);
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes <= MAX_FETCH_OUTPUT_BYTES) stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
if (stderr.length > MAX_FETCH_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_FETCH_STDERR_BYTES);
|
||||
});
|
||||
|
||||
const finish = (code: number | null) => {
|
||||
if (settled) return;
|
||||
// execFile's contract, which `describeFetchFailure` depends on: a non-zero exit rejects, so a real
|
||||
// git failure keeps its reason in `stderr` instead of becoming a green row with an empty one. `null`
|
||||
// means a signal, reachable only when something outside this runner killed the group: our own timeout
|
||||
// has already answered by then, and `settled` above is what keeps that answer the answer.
|
||||
if (code !== 0) return fail(undefined, code);
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve(stdout);
|
||||
};
|
||||
|
||||
child.on('error', (err) => fail(String(err)));
|
||||
child.on('close', (code) => finish(code));
|
||||
});
|
||||
};
|
||||
|
||||
export interface GitFetchEntry {
|
||||
/** Folder name of the repository (its root's basename), as the panel names it. */
|
||||
name: string;
|
||||
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||
path: string;
|
||||
ok: boolean;
|
||||
failure?: FetchFailure;
|
||||
}
|
||||
|
||||
export interface GitFetchReport {
|
||||
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
|
||||
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||
reason?: 'remote' | 'docker';
|
||||
error?: string;
|
||||
/** One entry per repository fetched, in the same order the panel lists them. */
|
||||
repos: GitFetchEntry[];
|
||||
/** More than `repoLimit` repositories were found; only the first were fetched. */
|
||||
reposTruncated: boolean;
|
||||
/** The most repositories this fetch would list (the caller's setting, or `MAX_REPOS`). */
|
||||
repoLimit?: number;
|
||||
/** When the last repository finished, so the panel can say what the "behind" number is now based on. */
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one run did, in the shape a session keeps and the Session Options Git row shows. `trigger` says
|
||||
* whether the user clicked or the interval fired, because a number the user did not ask for should read
|
||||
* differently from one they did.
|
||||
*/
|
||||
/** What a run was, and why it happened. */
|
||||
export type GitFetchTrigger = 'click' | 'interval';
|
||||
|
||||
export interface GitFetchStatus {
|
||||
ranAt: number;
|
||||
trigger: GitFetchTrigger;
|
||||
/** As in the report: `ok`, or the state that stopped the run. */
|
||||
state: GitFetchReport['state'];
|
||||
/** Repositories this run covered. */
|
||||
repos: number;
|
||||
/** How many of them could not be fetched. */
|
||||
failed: number;
|
||||
/** The first failure, named: enough for one row without reprinting the whole list. */
|
||||
firstFailure?: { name: string; code: FetchFailureCode };
|
||||
}
|
||||
|
||||
/**
|
||||
* One run, with the trigger that caused it. A joiner gets the run already going, so what it reports is that
|
||||
* run's trigger rather than the one that made it ask: a click that joins an interval run did not fetch
|
||||
* anything itself, and the row the panel shows has to describe the fetch that actually happened.
|
||||
*/
|
||||
export interface GitFetchRun {
|
||||
report: GitFetchReport;
|
||||
trigger: GitFetchTrigger;
|
||||
}
|
||||
|
||||
export function gitFetchStatusFrom(report: GitFetchReport, trigger: GitFetchTrigger): GitFetchStatus {
|
||||
const failed = report.repos.filter((r) => !r.ok);
|
||||
const status: GitFetchStatus = {
|
||||
ranAt: report.fetchedAt,
|
||||
trigger,
|
||||
state: report.state,
|
||||
repos: report.repos.length,
|
||||
failed: failed.length,
|
||||
};
|
||||
const first = failed[0];
|
||||
if (first?.failure) status.firstFailure = { name: first.name, code: first.failure.code };
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Options for the fetch path: the same repository rules and limits as an overview, without its cache clock. */
|
||||
export interface GitFetchOptions {
|
||||
git?: GitRunner;
|
||||
home?: string;
|
||||
dockerWorkspaces?: string[];
|
||||
maxRepos?: number;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* The shared slot count for a whole scheduler tick. Passed in so `FETCH_CONCURRENCY` bounds the tick
|
||||
* rather than each folder separately; without it, a tick over more than `FETCH_CONCURRENCY` folders could
|
||||
* have that many git processes going at once inside each one.
|
||||
*/
|
||||
limiter?: Limiter;
|
||||
}
|
||||
|
||||
export const emptyReport = (state: GitFetchReport['state'], extra: Partial<GitFetchReport> = {}): GitFetchReport => ({
|
||||
state,
|
||||
repos: [],
|
||||
reposTruncated: false,
|
||||
fetchedAt: Date.now(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function fetchOne(dir: string, git: GitRunner, timeoutMs: number): Promise<FetchFailure | null> {
|
||||
try {
|
||||
await git(dir, buildFetchArgs(), { timeoutMs });
|
||||
return null;
|
||||
} catch (err) {
|
||||
return describeFetchFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every repository the panel would show for `cwd`, through the same resolver so the two cannot
|
||||
* disagree. A repository that failed keeps its row with the reason, exactly as an unreadable repository
|
||||
* stays in the status list: the panel must show that something was not fetched rather than quietly skip it.
|
||||
*/
|
||||
export async function fetchGitWorkspaceOverview(cwd: string, opts: GitFetchOptions = {}): Promise<GitFetchReport> {
|
||||
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||
if (where.kind === 'docker') return emptyReport('unsupported', { reason: 'docker' });
|
||||
if (where.kind === 'error') return emptyReport('error', { error: where.error });
|
||||
const dirs = where.kind === 'enclosing' ? [where.root] : where.dirs;
|
||||
if (!dirs.length) return emptyReport('not-a-repo');
|
||||
const { timeoutMs } = resolveOverviewLimits(opts);
|
||||
const runner = opts.git ?? runFetch;
|
||||
const run = opts.limiter ?? createLimiter(FETCH_CONCURRENCY);
|
||||
const failures = await run(dirs, (dir) => fetchOne(dir, runner, timeoutMs));
|
||||
const repos: GitFetchEntry[] = dirs.map((dir, i) => {
|
||||
const base = { name: basename(dir), path: relative(cwd, dir) || '.' };
|
||||
const failure = failures[i];
|
||||
return failure ? { ...base, ok: false, failure } : { ...base, ok: true };
|
||||
});
|
||||
return {
|
||||
state: 'ok',
|
||||
repos,
|
||||
reposTruncated: where.kind === 'children' ? where.truncated : false,
|
||||
repoLimit: resolveOverviewLimits(opts).maxRepos,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs already going, keyed by the resolved folder. A request for a folder another run is already working on
|
||||
* JOINS that run instead of starting a second `git fetch` in the same clone: two fetches there collide on
|
||||
* git's ref lock (`cannot lock ref 'refs/remotes/origin/main'`), which is what a click during a tick would
|
||||
* otherwise do. A joiner gets the run already going's answer, including its limits and its trigger: the point
|
||||
* is not to fetch twice, not to fetch with the freshest options, and a click that fetched nothing cannot
|
||||
* report itself as the fetch.
|
||||
*
|
||||
* Keyed by `resolve`, not `realpath`, because `realpath` is itself one of the unbounded fs calls rule #516
|
||||
* bounds for user-chosen paths; the scheduler probes the folder bounded and calls this. Two paths that
|
||||
* resolve the same are the same folder here; a workspace reached through a symlink resolves to the link, so
|
||||
* a link and its target can be fetched by two runs, the accepted trade for keeping the probe bounded.
|
||||
*/
|
||||
const lane = new Map<string, Promise<GitFetchRun>>();
|
||||
|
||||
export async function fetchWorkspaceOnce(
|
||||
folder: string,
|
||||
opts: GitFetchOptions = {},
|
||||
trigger: GitFetchTrigger
|
||||
): Promise<GitFetchRun> {
|
||||
const key = resolve(folder);
|
||||
const going = lane.get(key);
|
||||
// The joiner is handed the run already going as it is, trigger included: it did not cause that fetch, so it
|
||||
// must not report itself as having.
|
||||
if (going) return going;
|
||||
const run: Promise<GitFetchRun> = fetchGitWorkspaceOverview(key, opts)
|
||||
.then((report) => ({ report, trigger }))
|
||||
.finally(() => {
|
||||
// Only the run that is still the lane's own: a later run for the same folder must not be dropped by an
|
||||
// earlier one finishing late.
|
||||
if (lane.get(key) === run) lane.delete(key);
|
||||
});
|
||||
lane.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Test seam: an in-memory lane is not cleared by stopping the scheduler, so a test has to clear it itself. */
|
||||
export function clearGitFetchLane(): void {
|
||||
lane.clear();
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||
* the last fetch (the UI says so, and the Fetch button is a separate user-triggered path in
|
||||
* `src/git-fetch.ts`, never a poll); "ahead" and the unpushed list are exact against the
|
||||
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||
@@ -596,8 +597,55 @@ export async function discoverChildRepos(
|
||||
|
||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
/**
|
||||
* A slot count fixed ONCE, so callers share it instead of each getting its own bound (see the fetch path):
|
||||
* the count lives in the closure, so `limit` is the ceiling for everything going through THIS limiter at
|
||||
* once, not for each call to it, which is what makes a scheduler tick's bound honest.
|
||||
* ⚠️ One limiter cannot bound two levels of the same work: a caller that holds a slot and then waits for
|
||||
* another slot for the work IT starts cannot get one, because the holders are exactly the ones waiting. The
|
||||
* outer level needs its own bound (`mapLimited`), which is why the tick shares one for fetches and keeps a
|
||||
* separate one for folders.
|
||||
*/
|
||||
export type Limiter = <T, R>(items: T[], fn: (item: T) => Promise<R>) => Promise<R[]>;
|
||||
|
||||
export function createLimiter(limit: number): Limiter {
|
||||
let inUse = 0;
|
||||
const waiting: Array<() => void> = [];
|
||||
const acquire = (): Promise<void> => {
|
||||
if (inUse < limit) {
|
||||
inUse++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((done) => {
|
||||
waiting.push(() => {
|
||||
inUse++;
|
||||
done();
|
||||
});
|
||||
});
|
||||
};
|
||||
const release = () => {
|
||||
inUse--;
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
};
|
||||
return async <T, R>(items: T[], fn: (item: T) => Promise<R>): Promise<R[]> => {
|
||||
const out: R[] = new Array(items.length);
|
||||
await Promise.all(
|
||||
items.map(async (item, i) => {
|
||||
await acquire();
|
||||
try {
|
||||
out[i] = await fn(item);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})
|
||||
);
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. Per call; `createLimiter` shares one bound across calls. */
|
||||
export async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
@@ -634,7 +682,7 @@ export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: un
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceRepos =
|
||||
export type WorkspaceRepos =
|
||||
| { kind: 'docker' }
|
||||
| { kind: 'error'; error: string }
|
||||
| { kind: 'enclosing'; root: string }
|
||||
@@ -643,9 +691,10 @@ type WorkspaceRepos =
|
||||
/**
|
||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
|
||||
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
|
||||
* below the folder. The overview, the fetch route and the diff route all go through here, so they cannot
|
||||
* disagree about which repositories belong to a workspace.
|
||||
*/
|
||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||
export async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||
const now = opts.now ?? Date.now;
|
||||
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||
|
||||
+38
-3
@@ -42,6 +42,7 @@ import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
|
||||
import { gitFetchSettingsFrom, type GitFetchSettings } from './git-fetch.js';
|
||||
|
||||
/**
|
||||
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||
@@ -1158,8 +1159,9 @@ export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
/**
|
||||
* The same two keys for the keep-warm service, which keeps what it reads until the next
|
||||
* settings change. A missing file is the defaults, but any other read or parse failure rejects
|
||||
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
|
||||
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
|
||||
* rather than falling back: settings.json is replaced atomically, so such a failure is a real
|
||||
* read error or a file that does not parse (a hand edit), never a write in progress, and a
|
||||
* fallback kept from it would hold keep-warm off until the next keep-warm settings change.
|
||||
*/
|
||||
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
|
||||
let text: string;
|
||||
@@ -1169,7 +1171,40 @@ export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
|
||||
return keepWarmSettingsFrom(parseSettingsText(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Git fetch interval's app-wide default (`gitFetchEnabled`, absent = OFF) and its cap
|
||||
* (`gitFetchIntervalSeconds`, seconds between runs). Read FRESH on every tick by the scheduler, so a live
|
||||
* toggle takes effect on the next tick and turning it off actually stops the fetching. A missing file is
|
||||
* the defaults (OFF), but any other read or parse failure rejects and the scheduler skips that tick rather
|
||||
* than deciding from defaults. settings.json is replaced atomically (a temp file renamed into place, see
|
||||
* settings-writer.ts), so such a failure is a real read error or a file that does not parse (a hand edit),
|
||||
* never a write in progress, and the scheduler logs it once rather than every tick.
|
||||
*/
|
||||
export async function readGitFetchSettings(): Promise<GitFetchSettings> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return gitFetchSettingsFrom({});
|
||||
throw err;
|
||||
}
|
||||
return gitFetchSettingsFrom(parseSettingsText(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* settings.json's text for the two readers above, which reject a file that does not parse. The parser's
|
||||
* own message is never repeated, as in settings-writer.ts: V8 quotes the text around the fault, settings.json
|
||||
* can hold an API key (`voiceSettings.apiKey`), and both callers log the error they get.
|
||||
*/
|
||||
function parseSettingsText(text: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`${SETTINGS_PATH} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
import { DISCORD_RECEIVED_DIR, UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -159,14 +159,14 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Ignore common heavy directories for performance
|
||||
ignored: (path: string) => {
|
||||
// Skip node_modules, .git, and other heavy directories, and Codeman's
|
||||
// own upload folders: a pdf the user handed to the agent is not a file
|
||||
// the agent produced.
|
||||
// own upload folders: a pdf the user handed to the agent, by upload or
|
||||
// from Discord, is not a file the agent produced.
|
||||
if (
|
||||
path.includes('/node_modules/') ||
|
||||
path.includes('/.git/') ||
|
||||
path.includes('/dist/') ||
|
||||
path.includes('/.next/') ||
|
||||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
|
||||
[...UPLOAD_DIR_NAMES, DISCORD_RECEIVED_DIR].some((name) => path.includes(`/${name}/`))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
/**
|
||||
* @fileoverview Offline reader for saved Codex conversations, behind
|
||||
* `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from
|
||||
* disk directly: no HTTP route, no running server and no managed session.
|
||||
*
|
||||
* ## Selecting conversations
|
||||
*
|
||||
* Exactly one selector: `file` (one rollout, no store search), `sessionId` (a
|
||||
* native thread id or unique prefix of at least 8 characters) or
|
||||
* `rootSessionId` (a full id plus every transitive descendant). Identity always
|
||||
* comes from each file's `session_meta`, never from its filename.
|
||||
*
|
||||
* ## Budgets
|
||||
*
|
||||
* Three separate bounds, each raisable from the CLI up to a fixed maximum, and
|
||||
* each failing closed with an error rather than returning a partial answer:
|
||||
*
|
||||
* - `metadataBytes`: the head reads that attribute every rollout in the store.
|
||||
* A `session_meta` line embeds Codex's base instructions (about 20 KiB on
|
||||
* every rollout measured), so this scales with the store, not the query,
|
||||
* and is kept apart from the transcript budget: a lookup on a store of
|
||||
* thousands of rollouts must not spend the budget meant for the transcripts
|
||||
* it returns. Each file's head read is also capped at 128 KiB.
|
||||
* - `scanBytes`: the selected transcripts, read whole.
|
||||
* - `maxEntries`: directory entries visited.
|
||||
*
|
||||
* ## Forks and sub-agents
|
||||
*
|
||||
* A thread Codex forks or spawns as a sub-agent can carry a copy of its
|
||||
* parent's history in its own file (codex-rs `ForkPersistence::Copied`, written
|
||||
* by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the
|
||||
* parent's `session_meta`, see `keep_forked_rollout_item` in
|
||||
* `core/src/agent/control/spawn.rs`). Such a file starts with its own
|
||||
* `session_meta`, then the parent's, then the copied records, then one
|
||||
* `thread_settings_applied` event, then the child's own records. The copy
|
||||
* already lives in the parent's own file, so it is dropped here:
|
||||
*
|
||||
* - Paginated history (`history_mode: "paginated"`) numbers every record with
|
||||
* an `ordinal` and declares `subagent_history_start_ordinal`; records below
|
||||
* it are inherited, exactly as Codex's own projection treats them
|
||||
* (`thread-store/src/local/thread_history_materialization.rs`). A paginated
|
||||
* thread with a `history_base` references its prefix in another file and has
|
||||
* nothing inherited to drop.
|
||||
* - Legacy history has no ordinals. The prefix runs from the inherited
|
||||
* `session_meta` to the `thread_settings_applied` event Codex appends after
|
||||
* the copy. A legacy copy keeps the parent's own `thread_settings_applied`
|
||||
* events too, so when more than one candidate follows, the boundary is the
|
||||
* last one whose preceding messages are still a prefix of the parent's own
|
||||
* messages, read from the parent's file (once: a family read that selects
|
||||
* the parent reuses that same read, so no file is charged to `scanBytes`
|
||||
* twice). Without a readable parent file that cannot be proven: the cut then
|
||||
* falls at the LAST candidate, so parent text is never attributed to the
|
||||
* child, and the result says so (`unverifiedPrefixes`, `complete: false`).
|
||||
* Any failure of the parent's read other than a budget leads here, rather
|
||||
* than failing the child whose own file is intact.
|
||||
*
|
||||
* A later `session_meta` must be the file's own id or a declared ancestor
|
||||
* (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and
|
||||
* transitively the ancestors those declare); any other id still refuses the
|
||||
* whole file rather than attribute one conversation's answer to another.
|
||||
*
|
||||
* ## Compressed rollouts
|
||||
*
|
||||
* Codex's `local_thread_store_compression` feature rewrites cold rollouts to
|
||||
* `*.jsonl.zst` in place. This reader does not decompress them: a store scan
|
||||
* counts each one in `compressedFiles` (so `complete` is false and a not-found
|
||||
* error names them), skipping one whose plain `.jsonl` sibling exists, as
|
||||
* Codex's own discovery does. A Zstandard file given to `file` is refused by
|
||||
* its magic number, whatever its name.
|
||||
*
|
||||
* ## Live rollouts
|
||||
*
|
||||
* A running Codex appends to its rollout. Reads take a snapshot: the file's
|
||||
* identity (device and inode) must match the attributed one and its size may
|
||||
* only have grown; everything up to the last complete line is read, and an
|
||||
* unterminated tail is reported as a partial record.
|
||||
*
|
||||
* ## Format
|
||||
*
|
||||
* The line shapes and the injected-context rule are shared with the Past
|
||||
* Sessions scanner through `codex-rollout-format.ts`.
|
||||
*
|
||||
* @module local-history
|
||||
*/
|
||||
import { constants } from 'node:fs';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js';
|
||||
|
||||
const KIB = 1024;
|
||||
const MIB = 1024 * KIB;
|
||||
const GIB = 1024 * MIB;
|
||||
|
||||
export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB;
|
||||
export const HISTORY_MAX_SCAN_BYTES = 256 * MIB;
|
||||
export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB;
|
||||
export const HISTORY_MAX_METADATA_BYTES = 16 * GIB;
|
||||
export const HISTORY_DEFAULT_MAX_ENTRIES = 65536;
|
||||
export const HISTORY_MAX_ENTRIES = 1048576;
|
||||
export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192;
|
||||
export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB;
|
||||
/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */
|
||||
const METADATA_HEAD_BYTES = 128 * KIB;
|
||||
/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */
|
||||
const METADATA_CHUNK_BYTES = 32 * KIB;
|
||||
/**
|
||||
* Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`:
|
||||
* `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`).
|
||||
*/
|
||||
const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst';
|
||||
/** The Zstandard frame magic number, little-endian on disk. */
|
||||
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
||||
/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */
|
||||
const MAX_DIRECTORY_DEPTH = 8;
|
||||
|
||||
const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i;
|
||||
const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
export interface LocalHistoryOptions {
|
||||
file?: string;
|
||||
sessionId?: string;
|
||||
rootSessionId?: string;
|
||||
sessionsRoot?: string;
|
||||
topic?: string;
|
||||
/** Inclusive UTC date, `YYYY-MM-DD`. */
|
||||
after?: string;
|
||||
/** Inclusive UTC date, `YYYY-MM-DD`. */
|
||||
before?: string;
|
||||
scanBytes?: number;
|
||||
metadataBytes?: number;
|
||||
maxEntries?: number;
|
||||
}
|
||||
export interface HistoryRecord {
|
||||
rootSessionId: string | null;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
role: 'user' | 'assistant';
|
||||
/** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */
|
||||
phase: string | null;
|
||||
message: string;
|
||||
}
|
||||
interface Source {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
parentId?: string;
|
||||
forkedFromId?: string;
|
||||
rootId?: string;
|
||||
/** `subagent_history_start_ordinal`: records below it are inherited. */
|
||||
historyStart?: number;
|
||||
timestamp: string;
|
||||
/** `dev:ino` of the attributed file. */
|
||||
identity: string;
|
||||
/** Size when attributed; a later read may only see it grow. */
|
||||
size: number;
|
||||
}
|
||||
export interface LocalHistoryResult {
|
||||
source: 'codex-jsonl';
|
||||
complete: boolean;
|
||||
sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>;
|
||||
records: HistoryRecord[];
|
||||
scan: {
|
||||
/** Transcript bytes read (`--scan-bytes`). */
|
||||
bytesRead: number;
|
||||
/** Head bytes read to attribute rollouts (`--metadata-bytes`). */
|
||||
metadataBytesRead: number;
|
||||
entries: number;
|
||||
malformedRecords: number;
|
||||
partialRecords: number;
|
||||
skippedSymlinks: number;
|
||||
unattributedFiles: number;
|
||||
/**
|
||||
* Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`)
|
||||
* this reader cannot open. Their threads are missing from the result.
|
||||
*/
|
||||
compressedFiles: number;
|
||||
unreadableDirectories: number;
|
||||
/** Inherited fork or sub-agent prefixes dropped (informational). */
|
||||
inheritedPrefixes: number;
|
||||
/** Inherited prefixes that never reached their end marker. */
|
||||
incompletePrefixes: number;
|
||||
/** Legacy prefixes whose end could not be checked against the parent's own file. */
|
||||
unverifiedPrefixes: number;
|
||||
};
|
||||
}
|
||||
interface Budget {
|
||||
transcriptRemaining: number;
|
||||
metadataRemaining: number;
|
||||
entriesRemaining: number;
|
||||
scan: LocalHistoryResult['scan'];
|
||||
/** Paths behind `scan.compressedFiles`, for the not-found error. */
|
||||
compressedPaths: string[];
|
||||
}
|
||||
|
||||
/** A budget ran out: always fatal, never counted. */
|
||||
class HistoryBudgetError extends Error {}
|
||||
/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */
|
||||
class UnattributableFileError extends Error {}
|
||||
|
||||
function object(value: unknown): Json {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {};
|
||||
}
|
||||
function string(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
function fullId(value: unknown): string | undefined {
|
||||
const id = string(value);
|
||||
return id && FULL_ID.test(id) ? id.toLowerCase() : undefined;
|
||||
}
|
||||
function fileIdentity(value: { dev: number; ino: number }): string {
|
||||
return `${value.dev}:${value.ino}`;
|
||||
}
|
||||
function validDate(value: string | undefined): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
Number.isFinite(Date.parse(value)) &&
|
||||
new Date(value).toISOString().slice(0, 10) === value)
|
||||
);
|
||||
}
|
||||
/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */
|
||||
function utcDate(timestamp: string): string | undefined {
|
||||
const ms = Date.parse(timestamp);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined;
|
||||
}
|
||||
function checkBudget(value: number, max: number, flag: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`);
|
||||
}
|
||||
function validate(options: LocalHistoryOptions): void {
|
||||
if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1)
|
||||
throw new Error('Select exactly one of --file, --session-id, or --root-session-id');
|
||||
if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required');
|
||||
if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id');
|
||||
if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId))
|
||||
throw new Error('Invalid root session id');
|
||||
if (
|
||||
!validDate(options.after) ||
|
||||
!validDate(options.before) ||
|
||||
(options.after && options.before && options.after > options.before)
|
||||
)
|
||||
throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)');
|
||||
if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic');
|
||||
checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes');
|
||||
checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes');
|
||||
checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries');
|
||||
}
|
||||
async function regularFile(path: string): Promise<FileHandle> {
|
||||
// O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat
|
||||
// validates the opened descriptor, not a previously inspected pathname.
|
||||
if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink');
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
if (!(await handle.stat()).isFile()) {
|
||||
await handle.close();
|
||||
throw new Error('History source must be a regular file');
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
async function readAt(handle: FileHandle, position: number, size: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(size);
|
||||
let filled = 0;
|
||||
while (filled < size) {
|
||||
const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled);
|
||||
if (!bytesRead) break;
|
||||
filled += bytesRead;
|
||||
}
|
||||
return buffer.subarray(0, filled);
|
||||
}
|
||||
async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) {
|
||||
if (size > budget.metadataRemaining)
|
||||
throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes');
|
||||
const chunk = await readAt(handle, position, size);
|
||||
budget.metadataRemaining -= chunk.length;
|
||||
budget.scan.metadataBytesRead += chunk.length;
|
||||
return chunk;
|
||||
}
|
||||
async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise<Buffer> {
|
||||
if (size > budget.transcriptRemaining)
|
||||
throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes');
|
||||
const buffer = await readAt(handle, 0, size);
|
||||
budget.transcriptRemaining -= buffer.length;
|
||||
budget.scan.bytesRead += buffer.length;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
type Identity = Omit<Source, 'identity' | 'size'>;
|
||||
function metadata(value: unknown, path: string): Identity | undefined {
|
||||
const entry = object(value);
|
||||
if (entry.type !== 'session_meta') return undefined;
|
||||
const p = object(entry.payload);
|
||||
const sessionId = fullId(p.id) ?? fullId(p.session_id);
|
||||
if (!sessionId) return undefined;
|
||||
const spawn = object(object(object(p.source).subagent).thread_spawn);
|
||||
const forkedFromId = fullId(p.forked_from_id);
|
||||
// A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread.
|
||||
const parentId =
|
||||
fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId;
|
||||
// Codex: "session_id is equal to the root thread's ID".
|
||||
const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id);
|
||||
const start = p.subagent_history_start_ordinal;
|
||||
return {
|
||||
path,
|
||||
sessionId,
|
||||
parentId,
|
||||
forkedFromId,
|
||||
rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined,
|
||||
historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined,
|
||||
timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '',
|
||||
};
|
||||
}
|
||||
function sameIdentity(a: Identity, b: Identity): boolean {
|
||||
return (
|
||||
a.sessionId === b.sessionId &&
|
||||
a.parentId === b.parentId &&
|
||||
a.forkedFromId === b.forkedFromId &&
|
||||
a.rootId === b.rootId &&
|
||||
a.historyStart === b.historyStart
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `session_meta` within the file's head. Problems with the file
|
||||
* itself throw {@link UnattributableFileError}; a budget throws
|
||||
* {@link HistoryBudgetError}; a short file with no metadata yet returns undefined.
|
||||
*/
|
||||
async function readMetadata(path: string, budget: Budget): Promise<Source | undefined> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await regularFile(path);
|
||||
} catch (error) {
|
||||
throw new UnattributableFileError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
const limit = Math.min(before.size, METADATA_HEAD_BYTES);
|
||||
let buffer = Buffer.alloc(0);
|
||||
let parsed = 0;
|
||||
while (buffer.length < limit) {
|
||||
const chunk = await readMetadataChunk(
|
||||
handle,
|
||||
buffer.length,
|
||||
Math.min(METADATA_CHUNK_BYTES, limit - buffer.length),
|
||||
budget
|
||||
);
|
||||
if (!chunk.length) break;
|
||||
if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
|
||||
throw new UnattributableFileError(
|
||||
'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read'
|
||||
);
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
let end = buffer.indexOf(10, parsed);
|
||||
while (end >= 0) {
|
||||
const line = buffer.subarray(parsed, end);
|
||||
parsed = end + 1;
|
||||
end = buffer.indexOf(10, parsed);
|
||||
if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(line.toString('utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const found = metadata(value, path);
|
||||
if (!found) continue;
|
||||
// session_meta is written once; a growing file is a live rollout, not a change.
|
||||
const after = await handle.stat();
|
||||
if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size)
|
||||
throw new UnattributableFileError('History source changed during metadata scan');
|
||||
return { ...found, identity: fileIdentity(before), size: before.size };
|
||||
}
|
||||
}
|
||||
if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan');
|
||||
if (before.size > METADATA_HEAD_BYTES)
|
||||
throw new UnattributableFileError('No session metadata within the first 128 KiB');
|
||||
return undefined;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function findSources(root: string, budget: Budget): Promise<Source[]> {
|
||||
// The root may itself be a symlink, as the Past Sessions scanner allows
|
||||
// (`codex-transcript.ts` reads it with readdir, which follows it); links
|
||||
// BELOW the root are never followed by either reader.
|
||||
let rootStat;
|
||||
try {
|
||||
rootStat = await stat(root);
|
||||
} catch {
|
||||
throw new Error(`Sessions root not found: ${root}`);
|
||||
}
|
||||
if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory');
|
||||
const sources: Source[] = [];
|
||||
const visit = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted');
|
||||
let entries;
|
||||
try {
|
||||
entries = await opendir(dir);
|
||||
} catch {
|
||||
budget.scan.unreadableDirectories++;
|
||||
return;
|
||||
}
|
||||
for await (const entry of entries) {
|
||||
if (--budget.entriesRemaining < 0)
|
||||
throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
|
||||
budget.scan.entries++;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
budget.scan.skippedSymlinks++;
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
// Recheck immediately before descending; never intentionally follow links.
|
||||
const now = await lstat(path).catch(() => undefined);
|
||||
if (!now?.isDirectory()) {
|
||||
budget.scan.unreadableDirectories++;
|
||||
continue;
|
||||
}
|
||||
await visit(path, depth + 1);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
let source: Source | undefined;
|
||||
try {
|
||||
source = await readMetadata(path, budget);
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnattributableFileError)) throw error;
|
||||
}
|
||||
if (source) sources.push(source);
|
||||
else budget.scan.unattributedFiles++;
|
||||
} else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) {
|
||||
// Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`),
|
||||
// and that sibling is visited (or counted) on its own.
|
||||
const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined);
|
||||
if (plain) continue;
|
||||
budget.scan.compressedFiles++;
|
||||
budget.compressedPaths.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root, 0);
|
||||
return sources;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
representation: 'event' | 'modern' | 'response';
|
||||
role: 'user' | 'assistant';
|
||||
message: string;
|
||||
phase: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
function message(value: unknown): Message | undefined {
|
||||
const entry = object(value);
|
||||
const p = object(entry.payload);
|
||||
if (entry.isSidechain || p.isSidechain) return undefined;
|
||||
const found = codexMessageFromLine(value);
|
||||
if (!found) return undefined;
|
||||
// Codex context is not a human turn. Real prompts also arrive as events, which are never filtered.
|
||||
if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined;
|
||||
return {
|
||||
role: found.role,
|
||||
message: found.text,
|
||||
// Codex records a phase on assistant messages only; a user turn has none.
|
||||
phase: found.role === 'assistant' ? (found.phase ?? null) : null,
|
||||
representation: found.shape === 'item' ? 'modern' : found.shape,
|
||||
timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '',
|
||||
};
|
||||
}
|
||||
/** Native events and response_item commonly duplicate one accepted message. */
|
||||
function collapse(items: Message[]): Message[] {
|
||||
const records: Message[] = [];
|
||||
let representations = new Set<Message['representation']>();
|
||||
for (const raw of items) {
|
||||
const item = { ...raw };
|
||||
const previous = records.at(-1);
|
||||
if (
|
||||
previous?.role === item.role &&
|
||||
previous.message === item.message &&
|
||||
!representations.has(item.representation)
|
||||
) {
|
||||
representations.add(item.representation);
|
||||
if (!previous.timestamp) previous.timestamp = item.timestamp;
|
||||
if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase;
|
||||
continue;
|
||||
}
|
||||
records.push(item);
|
||||
representations = new Set([item.representation]);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
function isPrefixOf(prefix: Message[], whole: Message[]): boolean {
|
||||
return (
|
||||
prefix.length <= whole.length &&
|
||||
prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message)
|
||||
);
|
||||
}
|
||||
|
||||
interface ReadContext {
|
||||
/**
|
||||
* The ancestor's own messages, uncut, or undefined when its file is not in the
|
||||
* store or could not be read for any reason other than a budget.
|
||||
*/
|
||||
ancestorMessages(id: string): Promise<Message[] | undefined>;
|
||||
}
|
||||
type Piece = Message | { boundary: true };
|
||||
interface LegacyPrefix {
|
||||
ancestorId: string;
|
||||
/** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */
|
||||
pieces: Piece[];
|
||||
}
|
||||
/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */
|
||||
interface ParsedSource {
|
||||
/** Every message in file order, inherited copies included: what a child's copy of this thread holds. */
|
||||
raw: Message[];
|
||||
/** Messages before any legacy inherited prefix, paginated inherited records already dropped. */
|
||||
own: Message[];
|
||||
legacy?: LegacyPrefix;
|
||||
}
|
||||
|
||||
async function parseSource(source: Source, budget: Budget): Promise<ParsedSource> {
|
||||
const handle = await regularFile(source.path);
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
const now = await handle.stat();
|
||||
// A live rollout only grows. A different file or a shorter one is a rewrite.
|
||||
if (fileIdentity(now) !== source.identity || now.size < source.size)
|
||||
throw new Error('History source changed after attribution; retry explicitly');
|
||||
buffer = await readTranscript(handle, now.size, budget);
|
||||
if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// Snapshot up to the last complete line: a running Codex may be mid-append.
|
||||
const end = buffer.lastIndexOf(10) + 1;
|
||||
const complete = buffer.subarray(0, end);
|
||||
if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++;
|
||||
if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8');
|
||||
|
||||
const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id));
|
||||
const raw: Message[] = [];
|
||||
const own: Message[] = [];
|
||||
let legacy: LegacyPrefix | undefined;
|
||||
let lastOrdinal = -1;
|
||||
for (const line of complete.toString('utf8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
budget.scan.malformedRecords++;
|
||||
continue;
|
||||
}
|
||||
const entry = object(parsed);
|
||||
const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined;
|
||||
if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal);
|
||||
if (source.historyStart !== undefined && ordinal === undefined) {
|
||||
// Codex's projection skips a paginated record without an ordinal; so does this, visibly.
|
||||
budget.scan.malformedRecords++;
|
||||
continue;
|
||||
}
|
||||
const inherited = source.historyStart !== undefined && ordinal! < source.historyStart;
|
||||
if (entry.type === 'session_meta') {
|
||||
const declared = metadata(parsed, source.path);
|
||||
if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue;
|
||||
if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId))
|
||||
throw new Error('History source contains conflicting native metadata; attribution refused');
|
||||
// An ancestor that was itself a fork carries its own ancestors' metadata in the copy.
|
||||
for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id);
|
||||
if (inherited || legacy) continue;
|
||||
budget.scan.inheritedPrefixes++;
|
||||
legacy = { ancestorId: declared.sessionId, pieces: [] };
|
||||
continue;
|
||||
}
|
||||
if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
|
||||
if (!inherited) legacy.pieces.push({ boundary: true });
|
||||
continue;
|
||||
}
|
||||
const item = message(parsed);
|
||||
if (!item) continue;
|
||||
raw.push(item);
|
||||
if (inherited) continue;
|
||||
if (legacy) legacy.pieces.push(item);
|
||||
else own.push(item);
|
||||
}
|
||||
if (source.historyStart !== undefined && source.historyStart > 0) {
|
||||
budget.scan.inheritedPrefixes++;
|
||||
// Initialization died while copying the prefix: Codex refuses to resume such a file.
|
||||
if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++;
|
||||
}
|
||||
return { raw: collapse(raw), own, legacy };
|
||||
}
|
||||
|
||||
/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */
|
||||
async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise<Message[]> {
|
||||
const own = [...parsed.own];
|
||||
if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context)));
|
||||
return collapse(own);
|
||||
}
|
||||
|
||||
/** The child's own messages after a legacy inherited prefix. */
|
||||
async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise<Message[]> {
|
||||
const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : []));
|
||||
if (!boundaries.length) {
|
||||
budget.scan.incompletePrefixes++;
|
||||
return [];
|
||||
}
|
||||
const messagesBefore = (index: number) =>
|
||||
collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece)));
|
||||
let cut = boundaries[0];
|
||||
if (boundaries.length > 1) {
|
||||
// The last candidate whose preceding messages the parent's own file also holds.
|
||||
const parent = await context.ancestorMessages(prefix.ancestorId);
|
||||
const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : [];
|
||||
if (proven.length) cut = proven.at(-1)!;
|
||||
else {
|
||||
// Unprovable: drop up to the last candidate, so parent text is never attributed to the child.
|
||||
budget.scan.unverifiedPrefixes++;
|
||||
cut = boundaries.at(-1)!;
|
||||
}
|
||||
}
|
||||
return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece));
|
||||
}
|
||||
|
||||
function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] {
|
||||
const turns: Message[][] = [];
|
||||
for (const item of messages) {
|
||||
if (item.role === 'user' || !turns.length) turns.push([]);
|
||||
turns.at(-1)!.push(item);
|
||||
}
|
||||
const needle = options.topic?.trim().toLocaleLowerCase();
|
||||
return turns
|
||||
.filter((turn) => {
|
||||
const dateMatches =
|
||||
(!options.after && !options.before) ||
|
||||
turn.some((item) => {
|
||||
const date = utcDate(item.timestamp);
|
||||
return (
|
||||
date !== undefined &&
|
||||
(!options.after || date >= options.after) &&
|
||||
(!options.before || date <= options.before)
|
||||
);
|
||||
});
|
||||
return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle)));
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
/** Native ancestry is metadata evidence, not the conversation selected by a query. */
|
||||
function nativeRoot(source: Source, sources: Source[]): string | null {
|
||||
const seen = new Set<string>();
|
||||
let current = source;
|
||||
while (true) {
|
||||
if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry');
|
||||
seen.add(current.sessionId);
|
||||
if (current.rootId) return current.rootId;
|
||||
if (!current.parentId) return current.sessionId;
|
||||
const parents = sources.filter((candidate) => candidate.sessionId === current.parentId);
|
||||
if (parents.length === 0) return null;
|
||||
if (parents.length > 1) throw new Error('Native ancestor id is ambiguous');
|
||||
current = parents[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The not-found error, naming what the store held but could not attribute.
|
||||
* Codex names a rollout `rollout-<timestamp>-<thread id>.jsonl[.zst]`, so a
|
||||
* compressed file whose name carries the wanted id is named outright; the name
|
||||
* is only a hint here, never identity.
|
||||
*/
|
||||
function missingSessionMessage(wanted: string, budget: Budget): string {
|
||||
const { scan } = budget;
|
||||
const notes: string[] = [];
|
||||
if (scan.compressedFiles) {
|
||||
// `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id.
|
||||
const named = budget.compressedPaths.filter((path) => {
|
||||
const name = basename(path).toLowerCase();
|
||||
return name.startsWith('rollout-') && name.slice(28).startsWith(wanted);
|
||||
});
|
||||
const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : '');
|
||||
notes.push(
|
||||
named.length
|
||||
? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc <path> > copy.jsonl)`
|
||||
: `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read`
|
||||
);
|
||||
}
|
||||
if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`);
|
||||
return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`;
|
||||
}
|
||||
|
||||
export async function readLocalHistory(options: LocalHistoryOptions): Promise<LocalHistoryResult> {
|
||||
validate(options);
|
||||
const scan: LocalHistoryResult['scan'] = {
|
||||
bytesRead: 0,
|
||||
metadataBytesRead: 0,
|
||||
entries: 0,
|
||||
malformedRecords: 0,
|
||||
partialRecords: 0,
|
||||
skippedSymlinks: 0,
|
||||
unattributedFiles: 0,
|
||||
compressedFiles: 0,
|
||||
unreadableDirectories: 0,
|
||||
inheritedPrefixes: 0,
|
||||
incompletePrefixes: 0,
|
||||
unverifiedPrefixes: 0,
|
||||
};
|
||||
const budget: Budget = {
|
||||
transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
|
||||
metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
|
||||
entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
|
||||
scan,
|
||||
compressedPaths: [],
|
||||
};
|
||||
let sources: Source[];
|
||||
let attributionSources: Source[];
|
||||
if (options.file !== undefined) {
|
||||
let source: Source | undefined;
|
||||
try {
|
||||
source = await readMetadata(resolve(options.file), budget);
|
||||
} catch (error) {
|
||||
throw error instanceof UnattributableFileError ? new Error(error.message) : error;
|
||||
}
|
||||
if (!source) throw new Error('History source is missing valid native session metadata');
|
||||
sources = [source];
|
||||
attributionSources = sources;
|
||||
} else {
|
||||
const root = options.sessionsRoot ?? codexSessionsRoot();
|
||||
const all = await findSources(resolve(root), budget);
|
||||
const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase();
|
||||
const matches = all.filter((source) => source.sessionId.startsWith(wanted));
|
||||
if (!matches.length) throw new Error(missingSessionMessage(wanted, budget));
|
||||
if (matches.length !== 1) throw new Error('Native session id is ambiguous');
|
||||
const target = matches[0];
|
||||
attributionSources = all;
|
||||
const queryRootId = target.sessionId;
|
||||
sources = [target];
|
||||
if (options.rootSessionId) {
|
||||
const selected = new Set([target.sessionId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const source of all) {
|
||||
if (
|
||||
!selected.has(source.sessionId) &&
|
||||
(source.rootId === queryRootId || (source.parentId && selected.has(source.parentId)))
|
||||
) {
|
||||
selected.add(source.sessionId);
|
||||
sources.push(source);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (all.filter((other) => other.sessionId === source.sessionId).length !== 1)
|
||||
throw new Error('Native family contains ambiguous duplicated session ids');
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId));
|
||||
const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)]));
|
||||
// Each selected file is read once, whether the main loop or a child's boundary proof asks first.
|
||||
const parsedCache = new Map<Source, Promise<ParsedSource>>();
|
||||
const parse = (source: Source) => {
|
||||
let parsed = parsedCache.get(source);
|
||||
if (!parsed) {
|
||||
parsed = parseSource(source, budget);
|
||||
parsedCache.set(source, parsed);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const context: ReadContext = {
|
||||
// Raw messages need no boundary proof of their own, so this never recurses.
|
||||
async ancestorMessages(id) {
|
||||
const candidates = attributionSources.filter((source) => source.sessionId === id);
|
||||
if (candidates.length !== 1) return undefined;
|
||||
try {
|
||||
return (await parse(candidates[0])).raw;
|
||||
} catch (error) {
|
||||
// A budget always fails the read; any other failure only leaves the boundary unproven.
|
||||
if (error instanceof HistoryBudgetError) throw error;
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
const records: HistoryRecord[] = [];
|
||||
for (const source of sources) {
|
||||
records.push(
|
||||
...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({
|
||||
rootSessionId: roots.get(source.sessionId) ?? null,
|
||||
sessionId: source.sessionId,
|
||||
role: item.role,
|
||||
message: item.message,
|
||||
phase: item.phase,
|
||||
timestamp: item.timestamp,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (!records.length) throw new Error('No matching user/assistant evidence found');
|
||||
return {
|
||||
source: 'codex-jsonl',
|
||||
complete:
|
||||
scan.malformedRecords === 0 &&
|
||||
scan.partialRecords === 0 &&
|
||||
scan.skippedSymlinks === 0 &&
|
||||
scan.unattributedFiles === 0 &&
|
||||
scan.compressedFiles === 0 &&
|
||||
scan.unreadableDirectories === 0 &&
|
||||
scan.incompletePrefixes === 0 &&
|
||||
scan.unverifiedPrefixes === 0 &&
|
||||
[...roots.values()].every((root) => root !== null),
|
||||
sessions: sources.map((source) => ({
|
||||
sessionId: source.sessionId,
|
||||
rootSessionId: roots.get(source.sessionId) ?? null,
|
||||
...(source.parentId ? { parentSessionId: source.parentId } : {}),
|
||||
path: source.path,
|
||||
})),
|
||||
records,
|
||||
scan,
|
||||
};
|
||||
}
|
||||
/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
|
||||
export function validateOutputBytes(maxBytes: number): void {
|
||||
checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
|
||||
}
|
||||
/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
|
||||
export function formatLocalHistory(
|
||||
result: LocalHistoryResult,
|
||||
format: 'json' | 'text' = 'json',
|
||||
maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES
|
||||
): string {
|
||||
validateOutputBytes(maxBytes);
|
||||
const body =
|
||||
format === 'json'
|
||||
? JSON.stringify(result)
|
||||
: [
|
||||
`source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`,
|
||||
...result.records.map(
|
||||
(row) =>
|
||||
`[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}`
|
||||
),
|
||||
].join('\n\n');
|
||||
if (Buffer.byteLength(body) + 1 > maxBytes)
|
||||
throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes');
|
||||
return body;
|
||||
}
|
||||
+2
-1
@@ -38,6 +38,7 @@ import {
|
||||
buildSendBody,
|
||||
composerReadyMark,
|
||||
describeFailure,
|
||||
describeRequestError,
|
||||
httpRequest,
|
||||
inputRefusal,
|
||||
isSelfSession,
|
||||
@@ -497,7 +498,7 @@ export function createToolRunner(deps: ToolRunnerDeps) {
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
throw new ToolError(
|
||||
`Cannot reach Codeman at ${ctx.apiUrl}: ${getErrorMessage(err)}. Check that it is running. If every call ` +
|
||||
`Cannot reach Codeman at ${ctx.apiUrl}: ${describeRequestError(err)}. Check that it is running. If every call ` +
|
||||
'fails at once, a sandbox around this MCP server may be blocking network access to this machine.'
|
||||
);
|
||||
}
|
||||
|
||||
+14
-3
@@ -271,7 +271,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` (and `getPaneCurrentPath()`) per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -314,9 +314,10 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
// ========== Input ==========
|
||||
|
||||
/**
|
||||
* Send input to a session via tmux send-keys.
|
||||
* Send input to a session via tmux send-keys. The optional internal Enter-only
|
||||
* guard runs synchronously immediately before transport dispatch.
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): Promise<boolean>;
|
||||
sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean>;
|
||||
|
||||
// ========== Metadata ==========
|
||||
|
||||
@@ -395,6 +396,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last pane read
|
||||
* (`#{pane_current_path}`), or `undefined` when unknown. Read from the same
|
||||
* batched watcher as {@link getPaneExit}, so it costs no tmux call. A relative
|
||||
* path printed in the terminal resolves against it; it is never a
|
||||
* confinement root. A multiplexer without it leaves relative paths resolving
|
||||
* against `workingDir`.
|
||||
*/
|
||||
getPaneCurrentPath?(muxName: string): string | undefined;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
|
||||
@@ -338,9 +338,11 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
try {
|
||||
settings = await this.readSettings();
|
||||
} catch (err) {
|
||||
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
|
||||
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
|
||||
// than once a minute per session, and only a session that could nudge retries.
|
||||
// A failed read is retried (settings.json is replaced atomically, so a read never sees a
|
||||
// half-written file; a failure is a read error or a file that does not parse). A settings.json
|
||||
// that stays broken (a hand edit with a trailing comma) fails every read until it is fixed, so
|
||||
// it is logged once rather than once a minute per session, and only a session that could
|
||||
// nudge retries.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message !== this.settingsFailure) {
|
||||
this.settingsFailure = message;
|
||||
|
||||
@@ -203,6 +203,9 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
/** Path to the @fix_plan.md file being watched */
|
||||
private _fixPlanPath: string | null = null;
|
||||
|
||||
/** The path is on a REMOTE host (`setRemoteWorkingDir`): never read or watch it locally. */
|
||||
private _remote = false;
|
||||
|
||||
/** File watcher for @fix_plan.md */
|
||||
private _fixPlanWatcher: FSWatcher | null = null;
|
||||
|
||||
@@ -238,6 +241,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
* @param workingDir - The session's working directory
|
||||
*/
|
||||
setWorkingDir(workingDir: string): void {
|
||||
this._remote = false;
|
||||
this._workingDir = workingDir;
|
||||
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
||||
|
||||
@@ -248,12 +252,28 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
this.startWatchingFixPlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a REMOTE session's working directory (a path on the remote host) without reading or
|
||||
* watching it: the local disk holds nothing of it. The fix plan stays authoritative exactly as for a
|
||||
* local session, so the output-based completion guessers stay off (they are off for every session
|
||||
* with a working directory); the file reaches the tracker only through an explicit import
|
||||
* (`POST .../fix-plan/read`, which reads it over ssh).
|
||||
* @param workingDir - The session's working directory on the remote host
|
||||
*/
|
||||
setRemoteWorkingDir(workingDir: string): void {
|
||||
this.stopWatchingFixPlan();
|
||||
this._remote = true;
|
||||
this._workingDir = workingDir;
|
||||
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load @fix_plan.md from disk if it exists.
|
||||
* Called on initialization and when file changes are detected.
|
||||
* Called on initialization and when file changes are detected. A remote path is never read here:
|
||||
* the local disk would hold a missing path or an unrelated same-named file.
|
||||
*/
|
||||
async loadFixPlanFromDisk(): Promise<number> {
|
||||
if (!this._fixPlanPath) return 0;
|
||||
if (!this._fixPlanPath || this._remote) return 0;
|
||||
|
||||
try {
|
||||
if (!existsSync(this._fixPlanPath)) {
|
||||
@@ -281,7 +301,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
|
||||
* Reloads todos when the file is modified.
|
||||
*/
|
||||
private startWatchingFixPlan(): void {
|
||||
if (!this._fixPlanPath || !this._workingDir) return;
|
||||
if (!this._fixPlanPath || !this._workingDir || this._remote) return;
|
||||
|
||||
// Stop existing watcher if any
|
||||
this.stopWatchingFixPlan();
|
||||
|
||||
@@ -717,6 +717,15 @@ export class RalphTracker extends EventEmitter {
|
||||
this.fixPlanWatcher.setWorkingDir(workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a remote session's working directory without touching the local disk (see
|
||||
* `RalphFixPlanWatcher.setRemoteWorkingDir`): the fix plan stays authoritative, as for a local session.
|
||||
* @param workingDir - The session's working directory on the remote host
|
||||
*/
|
||||
setRemoteWorkingDir(workingDir: string): void {
|
||||
this.fixPlanWatcher.setRemoteWorkingDir(workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load @fix_plan.md from disk if it exists.
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* @module session-cli-registry-bridge
|
||||
*/
|
||||
|
||||
import { getDiscordStartupHint } from './discord-bridge.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
@@ -248,6 +249,10 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
|
||||
}
|
||||
|
||||
// The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its
|
||||
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
|
||||
engineValues.appendSystemPrompt = getDiscordStartupHint();
|
||||
|
||||
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
|
||||
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
|
||||
// "known to be unresolvable" and must not probe. The probe only ever runs from
|
||||
|
||||
+498
-19
@@ -30,6 +30,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
@@ -68,8 +69,11 @@ import {
|
||||
type StatusLineExporter,
|
||||
type KeepWarmStatus,
|
||||
} from './types.js';
|
||||
import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||
@@ -129,6 +133,14 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||
import {
|
||||
guardedComposerChrome,
|
||||
promptSha256,
|
||||
readGuardedSnapshotDetails,
|
||||
type GuardedInputSnapshot,
|
||||
} from './agent-staging.js';
|
||||
import { isKeyboardTerminalInput } from './terminal-input-classifier.js';
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
@@ -389,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */
|
||||
const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
/** Whether absolute `path` is `root` or lies under it (a lexical compare). */
|
||||
function isSameOrUnder(path: string, root: string): boolean {
|
||||
return path === root || path.startsWith(root === '/' ? '/' : `${root}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `path` from under `from` to the same place under `to`; a path not at or
|
||||
* under `from` comes back unchanged. Exported for unit testing.
|
||||
*/
|
||||
export function rebasePath(path: string, from: string, to: string): string {
|
||||
if (path === from) return to;
|
||||
if (path.startsWith(`${from}/`)) return to + path.slice(from.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
* client can spawn at the same size and avoid the resize-flicker / scrollback
|
||||
@@ -457,6 +484,17 @@ export interface ClaudeMessage {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write that a guarded submit's lease could not hold, because the per-lease hold
|
||||
* queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it.
|
||||
*/
|
||||
export class GuardedInputBusyError extends Error {
|
||||
constructor() {
|
||||
super('A guarded submit is in progress on this session; nothing was written. Retry the input.');
|
||||
this.name = 'GuardedInputBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
|
||||
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
|
||||
@@ -614,6 +652,12 @@ export class Session extends EventEmitter {
|
||||
private _keepWarmEnabled: boolean | undefined;
|
||||
/** Last status the keep-warm service published for this session; ephemeral. */
|
||||
private _keepWarm: KeepWarmStatus | null = null;
|
||||
/** Git fetch on an interval for this session; undefined = follow the app-wide default. Persisted. */
|
||||
private _gitFetchEnabled: boolean | undefined;
|
||||
/** Seconds between this session's fetch runs; undefined = follow the app-wide default. Persisted. */
|
||||
private _gitFetchIntervalSeconds: number | undefined;
|
||||
/** Last status the fetch scheduler published for this session; ephemeral. */
|
||||
private _gitFetch: GitFetchStatus | null = null;
|
||||
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
|
||||
private _keepAliveTurnUntil = 0;
|
||||
/** Last keystroke a person sent through a `fromUser` write. */
|
||||
@@ -717,6 +761,21 @@ export class Session extends EventEmitter {
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* The pane's live directory, from the pane-exit watcher's read. Runtime-only:
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
* spelling. `undefined` until the first resolution settles; `null` when there
|
||||
* is nothing to translate (no symlink in the path) or it could not be resolved.
|
||||
*/
|
||||
private _workspaceRealPath: string | null | undefined;
|
||||
private _workspaceRealPathPending = false;
|
||||
/** When a resolution that met an unreachable folder may be tried again (0 = never). */
|
||||
private _workspaceRealPathRetryAt = 0;
|
||||
/**
|
||||
* How many starts, attaches or relaunches are running for this session's
|
||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||
@@ -926,6 +985,10 @@ export class Session extends EventEmitter {
|
||||
lastRealPromptAt?: number;
|
||||
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** Restored Git fetch override for this session (see `gitFetchEnabled`). */
|
||||
gitFetchEnabled?: boolean;
|
||||
/** Restored Git fetch interval for this session (see `gitFetchIntervalSeconds`). */
|
||||
gitFetchIntervalSeconds?: number;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
@@ -1015,6 +1078,10 @@ export class Session extends EventEmitter {
|
||||
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
|
||||
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
|
||||
this._keepWarmEnabled = config.keepWarmEnabled;
|
||||
// Restored, like the keep-warm choice: the interval is the user's decision about their repositories
|
||||
// and outlives the run that made it, so a restart keeps it rather than falling back to the app default.
|
||||
this._gitFetchEnabled = config.gitFetchEnabled;
|
||||
this._gitFetchIntervalSeconds = config.gitFetchIntervalSeconds;
|
||||
// Restored chain: its tail is the conversation the CLI was actually on when
|
||||
// the server stopped, which outranks the launch id seeded just above. The
|
||||
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
|
||||
@@ -1296,6 +1363,17 @@ export class Session extends EventEmitter {
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One env var from this session's overrides, for internal path resolution only
|
||||
* (custom-model injection linking an isolated config dir back to a path that must
|
||||
* survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the
|
||||
* map can hold provider credentials and stays off the public `SessionState`.
|
||||
*/
|
||||
envOverrideValue(key: string): string | undefined {
|
||||
const value = this._envOverrides?.[key];
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
@@ -1308,6 +1386,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
/** Set the owning username (used by recovery to restore ownership). */
|
||||
set owner(username: string | undefined) {
|
||||
if (this._owner !== username) this._guardedPaneRevision++;
|
||||
this._owner = username;
|
||||
}
|
||||
|
||||
@@ -1412,11 +1491,13 @@ export class Session extends EventEmitter {
|
||||
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
|
||||
*/
|
||||
markClosing(closing: boolean): void {
|
||||
if (this._closing !== closing) this._guardedPaneRevision++;
|
||||
this._closing = closing;
|
||||
}
|
||||
|
||||
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
|
||||
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
|
||||
this._guardedPaneRevision++;
|
||||
this._paneLifecycleOps++;
|
||||
try {
|
||||
return await op();
|
||||
@@ -1443,10 +1524,104 @@ export class Session extends EventEmitter {
|
||||
* Ark0N/Codeman#446 review; both now persist on their success path.
|
||||
*/
|
||||
private clearPaneExitForNewPane(): void {
|
||||
this._guardedPaneRevision++;
|
||||
this.setPaneExit(undefined);
|
||||
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||
}
|
||||
|
||||
/** The pane's live working directory, when the pane read has reported one. */
|
||||
get paneCwd(): string | undefined {
|
||||
return this._paneCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the pane's live directory. Returns whether it CHANGED, so the caller
|
||||
* broadcasts only on a real transition.
|
||||
*
|
||||
* Scoped by {@link paneExitApplies}, the same rule as the exit reading: a
|
||||
* remote session's local pane runs the ssh client and a docker case's runs a
|
||||
* `docker exec`, so their pane directory is a host directory the agent was
|
||||
* never in, and a direct-PTY or rediscovered session has no pane Codeman can
|
||||
* vouch for. A relative value is refused rather than stored.
|
||||
*
|
||||
* tmux reads the pane's directory from the process cwd, which is always the
|
||||
* resolved path, while `workingDir` keeps the spelling the case was created
|
||||
* with. A workspace reached through a symlink would otherwise read as outside
|
||||
* itself, and the browser's preview routing (a lexical compare against
|
||||
* `workingDir`) would send every relative link to the attachment route. So a
|
||||
* reading at or under the workspace's resolved path is written back in
|
||||
* `workingDir`'s spelling. Until that resolution has settled, only a reading
|
||||
* lexically inside `workingDir` is accepted.
|
||||
*/
|
||||
setPaneCwd(next: string | undefined): boolean {
|
||||
let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined;
|
||||
if (resolved !== undefined) {
|
||||
if (
|
||||
this._workspaceRealPath === undefined ||
|
||||
(this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt)
|
||||
) {
|
||||
this._resolveWorkspaceRealPath();
|
||||
}
|
||||
if (this._workspaceRealPath === undefined) {
|
||||
// The workspace's resolved path is not known yet. A reading outside
|
||||
// `workingDir` could be the workspace itself seen through its symlink, so
|
||||
// it waits for the next tick rather than mis-route links for one.
|
||||
if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false;
|
||||
} else if (this._workspaceRealPath !== null) {
|
||||
resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling());
|
||||
}
|
||||
}
|
||||
if (resolved === this._paneCwd) return false;
|
||||
this._paneCwd = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `workingDir`'s symlinks in the background for {@link setPaneCwd}.
|
||||
*
|
||||
* Bounded: the folder can sit on a network mount that stopped answering, so it
|
||||
* is probed with `probePathKind()` first and the `realpath()` is raced against
|
||||
* the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no
|
||||
* translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a
|
||||
* folder that is absent or not a directory is settled for good.
|
||||
*/
|
||||
private _resolveWorkspaceRealPath(): void {
|
||||
if (this._workspaceRealPathPending) return;
|
||||
this._workspaceRealPathPending = true;
|
||||
const spelling = this._workspaceSpelling();
|
||||
void (async () => {
|
||||
let real: string | null = null;
|
||||
let retry = false;
|
||||
try {
|
||||
const kind = await probePathKind(spelling);
|
||||
if (kind === 'unknown') {
|
||||
retry = true;
|
||||
} else if (kind === 'directory') {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const answer = await Promise.race([
|
||||
realpath(spelling).catch(() => undefined),
|
||||
new Promise<undefined>((resolveTimeout) => {
|
||||
timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
if (answer === undefined) retry = true;
|
||||
else if (answer !== spelling && answer !== '/') real = answer;
|
||||
}
|
||||
} catch {
|
||||
retry = true;
|
||||
}
|
||||
this._workspaceRealPath = real;
|
||||
this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0;
|
||||
this._workspaceRealPathPending = false;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the mux layer observed of this pane's agent, and say whether
|
||||
* that changed the answer. The caller persists and broadcasts on a true.
|
||||
@@ -1579,6 +1754,37 @@ export class Session extends EventEmitter {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Git fetch override for this session; undefined means the app-wide setting applies. */
|
||||
get gitFetchEnabled(): boolean | undefined {
|
||||
return this._gitFetchEnabled;
|
||||
}
|
||||
|
||||
/** Interval override for this session; undefined means the app-wide setting applies. */
|
||||
get gitFetchIntervalSeconds(): number | undefined {
|
||||
return this._gitFetchIntervalSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session's override. The Session Options rows save on their own, so a caller passes what it has
|
||||
* and leaves the other half as it was; `undefined` on either means the app-wide setting applies again.
|
||||
*/
|
||||
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
|
||||
this._gitFetchEnabled = enabled;
|
||||
this._gitFetchIntervalSeconds = intervalSeconds;
|
||||
}
|
||||
|
||||
/** What the fetch scheduler last reported for this session, or null before its first run. */
|
||||
get gitFetch(): GitFetchStatus | null {
|
||||
return this._gitFetch;
|
||||
}
|
||||
|
||||
/** Replace the fetch status; true when it differs from the previous one. */
|
||||
setGitFetchStatus(status: GitFetchStatus): boolean {
|
||||
const changed = JSON.stringify(status) !== JSON.stringify(this._gitFetch);
|
||||
this._gitFetch = status;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
|
||||
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
|
||||
@@ -2093,6 +2299,7 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
@@ -2116,6 +2323,9 @@ export class Session extends EventEmitter {
|
||||
statusLineExporter: this.statusLineExporter,
|
||||
keepWarmEnabled: this._keepWarmEnabled,
|
||||
keepWarm: this._keepWarm ?? undefined,
|
||||
gitFetchEnabled: this._gitFetchEnabled,
|
||||
gitFetchIntervalSeconds: this._gitFetchIntervalSeconds,
|
||||
gitFetch: this._gitFetch ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
@@ -2620,7 +2830,7 @@ export class Session extends EventEmitter {
|
||||
if (entry?.launch.chain !== 'fallback') return options;
|
||||
|
||||
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||
const configDir = this._claudeConfigDir();
|
||||
const configDir = this.claudeConfigDir();
|
||||
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||
for (const candidate of candidates) {
|
||||
@@ -2642,8 +2852,11 @@ export class Session extends EventEmitter {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
/**
|
||||
* The session's Claude config dir when it has been relocated (#255), else undefined.
|
||||
* Public for the edit history reader, which looks for the transcript there.
|
||||
*/
|
||||
claudeConfigDir(): string | undefined {
|
||||
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||
// otherwise resolve to a relative path and read "no transcript" for everything.
|
||||
@@ -2652,7 +2865,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
/**
|
||||
* 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, codex's bare selected id) onto the CLI's `model`
|
||||
* launch param. Where that param
|
||||
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
|
||||
* ...) or the top-level `model` for an entry that declares none — so this stays a
|
||||
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
|
||||
@@ -4206,6 +4420,11 @@ export class Session extends EventEmitter {
|
||||
prompt: string,
|
||||
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
|
||||
): Promise<{ result: string; cost: number }> {
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
throw new Error('Guarded submission in progress');
|
||||
}
|
||||
this._guardedPaneRevision++;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.ptyProcess) {
|
||||
reject(new Error('Session already has a running process'));
|
||||
@@ -4715,6 +4934,229 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
|
||||
|
||||
private readonly _inspectionInstance = uuidv4();
|
||||
private _guardedPaneRevision = 0;
|
||||
private _guardedGeometryRevision = 0;
|
||||
private _guardedInputRevision = 0;
|
||||
private _guardedPendingWrites = 0;
|
||||
private _guardedSubmitInFlight = false;
|
||||
/**
|
||||
* Input that arrived while a guarded submit held the lease, in arrival order. Each
|
||||
* entry runs when the lease is released; an entry that returns a promise (a mux
|
||||
* write, a named key) is waited for before the next runs, so nothing overtakes it.
|
||||
*/
|
||||
private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = [];
|
||||
private _guardedHeldBytes = 0;
|
||||
/** True while the drain waits for a held asynchronous write to finish. */
|
||||
private _guardedHeldDraining = false;
|
||||
/** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */
|
||||
static readonly MAX_GUARDED_HELD_INPUTS = 256;
|
||||
/** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */
|
||||
static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024;
|
||||
/** Ephemeral identity, never persisted or based on a potentially reused pid alone. */
|
||||
get inspectionGeneration(): string {
|
||||
return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`;
|
||||
}
|
||||
/** Staging additionally binds geometry; transcript cursors survive a terminal resize. */
|
||||
get guardedInputGeneration(): string {
|
||||
return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`;
|
||||
}
|
||||
get guardedInputPendingWrites(): number {
|
||||
return this._guardedPendingWrites;
|
||||
}
|
||||
/**
|
||||
* True while a guarded submit holds the input lease (a few milliseconds). Input that
|
||||
* arrives in that span is held and written after it (see {@link guardedInputHeld}).
|
||||
*/
|
||||
get guardedSubmitInFlight(): boolean {
|
||||
return this._guardedSubmitInFlight;
|
||||
}
|
||||
/**
|
||||
* True while input is being held behind a guarded submit: the lease itself, or held
|
||||
* input not yet written after it. A write that is not routed through
|
||||
* {@link holdForGuardedLease} must refuse in this window, or it overtakes held input.
|
||||
* `write()` is the known exception: it refuses during the lease only, since the drain
|
||||
* writes held input through it, so a direct `write()` caller (cron's `paste` mode in
|
||||
* `cron/cron-service.ts`) can still land ahead of input that is draining.
|
||||
*/
|
||||
get guardedInputHeld(): boolean {
|
||||
return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining;
|
||||
}
|
||||
/**
|
||||
* Hold input behind a guarded submit's lease instead of refusing it.
|
||||
*
|
||||
* `free`: nothing is held, so the caller writes inline as it always did. `held`: `run`
|
||||
* is queued and runs, in arrival order with every other held input, once the lease is
|
||||
* released (whatever the submit's outcome). `full`: the per-lease cap is reached and
|
||||
* nothing was queued; the caller must stop delivering from that source until it can
|
||||
* resend in order. Refusing instead lost input: the browser's WebSocket path does not
|
||||
* wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then
|
||||
* rejected as a duplicate. `data`, when given, is observed as input on arrival, so a
|
||||
* keystroke held during the lease still moves the input revision at once.
|
||||
*/
|
||||
holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' {
|
||||
if (!this.guardedInputHeld) return 'free';
|
||||
if (data !== undefined) this._observeGuardedKeyboardInput(data);
|
||||
if (
|
||||
this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS ||
|
||||
this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES
|
||||
)
|
||||
return 'full';
|
||||
this._guardedHeld.push({ run, bytes });
|
||||
this._guardedHeldBytes += bytes;
|
||||
return 'held';
|
||||
}
|
||||
/**
|
||||
* Run an asynchronous write now, or in its turn behind a guarded submit's lease. The
|
||||
* turn lasts until the write settles, so later held input waits for it. Resolves
|
||||
* `whenFull` without running anything when the hold queue is full.
|
||||
*/
|
||||
private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> {
|
||||
if (!this.guardedInputHeld) return write();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const held = this.holdForGuardedLease(() => {
|
||||
const running = write();
|
||||
running.then(resolve, reject);
|
||||
return running;
|
||||
});
|
||||
if (held === 'full') {
|
||||
try {
|
||||
resolve(whenFull());
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Run held input in arrival order until it is all written or a new lease is taken. */
|
||||
private _drainGuardedHeld(): void {
|
||||
while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) {
|
||||
const entry = this._guardedHeld.shift()!;
|
||||
this._guardedHeldBytes -= entry.bytes;
|
||||
let running: void | Promise<unknown>;
|
||||
try {
|
||||
running = entry.run();
|
||||
} catch (err) {
|
||||
console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err));
|
||||
continue;
|
||||
}
|
||||
if (running && typeof running.then === 'function') {
|
||||
this._guardedHeldDraining = true;
|
||||
void running
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this._guardedHeldDraining = false;
|
||||
this._drainGuardedHeld();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Expose only the path selection needed for attributed Codex evidence, never credentials. */
|
||||
getCodexHomeOverride(): string | undefined {
|
||||
return this._envOverrides?.CODEX_HOME || undefined;
|
||||
}
|
||||
private _observeGuardedKeyboardInput(data: string): void {
|
||||
if (data && isKeyboardTerminalInput(data)) this._guardedInputRevision++;
|
||||
}
|
||||
guardedInputSnapshot(): GuardedInputSnapshot | null {
|
||||
return this._readGuardedInputSnapshot(false);
|
||||
}
|
||||
private _readGuardedInputSnapshot(allowLease: boolean): GuardedInputSnapshot | null {
|
||||
if (
|
||||
this._isStopped ||
|
||||
this._closing ||
|
||||
this.paneLifecycleInFlight ||
|
||||
this._guardedPendingWrites ||
|
||||
(!allowLease && this.guardedInputHeld) ||
|
||||
this.remote ||
|
||||
this.docker ||
|
||||
this.paneExit ||
|
||||
!this._muxSession
|
||||
)
|
||||
return null;
|
||||
// Only a CLI whose registry entry describes its composer chrome takes guarded input.
|
||||
const chrome = guardedComposerChrome(getCli(this.mode));
|
||||
if (!chrome) return null;
|
||||
const capture: import('./mux-interface.js').PaneCaptureOptions = {};
|
||||
const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture);
|
||||
if (!screen) return null;
|
||||
// tmux's replay has CUP addresses rather than line breaks. Read the SAME
|
||||
// fresh frame for dialog/work chrome and exact composer/caret proof.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const plain = stripAnsi(screen.replace(/\x1b\[\d+;\d+H/g, '\n'));
|
||||
const working = this._workingLinePattern();
|
||||
working.lastIndex = 0;
|
||||
if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null;
|
||||
const composer = readGuardedSnapshotDetails(screen, chrome);
|
||||
return composer === null
|
||||
? null
|
||||
: {
|
||||
generation: this.guardedInputGeneration,
|
||||
revision: this._guardedInputRevision,
|
||||
composerSha: promptSha256(composer.composer),
|
||||
availableColumns: Number.isFinite(capture.capturedGeometry?.cols)
|
||||
? capture.capturedGeometry!.cols - composer.prefixWidth
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
/** Stage through ordinary delivery, after cancelling any older Enter retry timer. */
|
||||
async stageGuardedInput(message: string): Promise<boolean> {
|
||||
if (/[\r\n]/.test(message)) return false;
|
||||
if (this._guardedPendingWrites || this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
return false;
|
||||
}
|
||||
this._submitVerifier?.cancel();
|
||||
return this.writeViaMux(message, { fromUser: true });
|
||||
}
|
||||
/** One conditional Enter. An ambiguous acknowledgement is never replayed. */
|
||||
async submitGuardedInput(expected: GuardedInputSnapshot): Promise<boolean> {
|
||||
const matches = (current: GuardedInputSnapshot | null): boolean =>
|
||||
!!current &&
|
||||
current.generation === expected.generation &&
|
||||
current.revision === expected.revision &&
|
||||
current.composerSha === expected.composerSha &&
|
||||
current.availableColumns === expected.availableColumns;
|
||||
if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false;
|
||||
this._guardedSubmitInFlight = true;
|
||||
let dispatched = false;
|
||||
try {
|
||||
const sent = await this._mux.sendInput(this.id, '\r', {
|
||||
beforeDispatch: () => {
|
||||
if (!matches(this._readGuardedInputSnapshot(true))) return false;
|
||||
this._guardedInputRevision++;
|
||||
dispatched = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (sent && dispatched) this._emitSubmittedPrompt(this._trackSubmit('\r', { fromUser: true }));
|
||||
return sent && dispatched;
|
||||
} finally {
|
||||
this._guardedSubmitInFlight = false;
|
||||
this._drainGuardedHeld();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Lease/pending boundary for named keys whose bytes bypass the ordinary write methods.
|
||||
* During a guarded submit the operation waits for the lease like any other write;
|
||||
* only a full hold queue refuses it, with {@link GuardedInputBusyError}.
|
||||
*/
|
||||
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
|
||||
this._guardedInputRevision++;
|
||||
return this._inGuardedTurn(
|
||||
async () => {
|
||||
this._guardedPendingWrites++;
|
||||
try {
|
||||
await operation();
|
||||
} finally {
|
||||
this._guardedPendingWrites--;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
throw new GuardedInputBusyError();
|
||||
}
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Sends input directly to the PTY process.
|
||||
*
|
||||
@@ -4734,6 +5176,9 @@ export class Session extends EventEmitter {
|
||||
* input could disappear while the caller believed it had been delivered.
|
||||
*/
|
||||
write(data: string, options: SessionWriteOptions = {}): boolean {
|
||||
this._observeGuardedKeyboardInput(data);
|
||||
// The lease only, not the drain, which writes held input through here (see guardedInputHeld).
|
||||
if (this._guardedSubmitInFlight) return false;
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
@@ -4782,6 +5227,7 @@ export class Session extends EventEmitter {
|
||||
* separator. Reports submissions like a write would (a line feed never is one).
|
||||
*/
|
||||
trackUserInput(data: string): void {
|
||||
this._observeGuardedKeyboardInput(data);
|
||||
if (!this._acceptsPrompts) return;
|
||||
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
|
||||
}
|
||||
@@ -4803,6 +5249,7 @@ export class Session extends EventEmitter {
|
||||
* their prompt must count as real and alert normally.
|
||||
*/
|
||||
markPromptSubmitted(prompt?: string): void {
|
||||
this._guardedInputRevision++;
|
||||
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
|
||||
this._stampSubmit();
|
||||
}
|
||||
@@ -4898,22 +5345,39 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) {
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
this._verifySubmitted(data);
|
||||
this._observeGuardedKeyboardInput(data);
|
||||
// A guarded submit's lease lasts for its Enter only: wait for it, in arrival order,
|
||||
// rather than refusing, so an approval answer or a programmatic prompt is not
|
||||
// dropped. Without a lease the write starts synchronously, so the pending count
|
||||
// rises before any await and no lease can be taken while it is in flight.
|
||||
return this._inGuardedTurn(
|
||||
() => this._writeViaMuxNow(data, options),
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> {
|
||||
this._guardedPendingWrites++;
|
||||
try {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) {
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
this._verifySubmitted(data);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
return sent;
|
||||
// Fallback to PTY write
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this._guardedPendingWrites--;
|
||||
}
|
||||
// Fallback to PTY write
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4941,7 +5405,16 @@ export class Session extends EventEmitter {
|
||||
this._isStopped || !this._mux || !this._muxSession
|
||||
? null
|
||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||
sendEnter: () => {
|
||||
// A retry Enter is never held behind a guarded submit: once that submit's
|
||||
// Enter went out, a re-press would submit whatever is on the composer next.
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
this._submitVerifier?.cancel();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this.writeViaMux('\r');
|
||||
},
|
||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
||||
// Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back
|
||||
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
|
||||
@@ -4970,6 +5443,7 @@ export class Session extends EventEmitter {
|
||||
* `resize()` skipped a real resize that happened to match them.
|
||||
*/
|
||||
private _notePtySpawnGeometry(cols: number, rows: number): void {
|
||||
this._guardedPaneRevision++;
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
}
|
||||
@@ -5092,6 +5566,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._guardedGeometryRevision++;
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (!IS_TEST_MODE && this._mux && this._muxSession) {
|
||||
@@ -5108,6 +5583,10 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Legacy method for sending input - wraps runPrompt
|
||||
async sendInput(input: string): Promise<void> {
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
throw new Error('Guarded submission in progress');
|
||||
}
|
||||
this._status = 'busy';
|
||||
this._markActivity(true);
|
||||
this.runPrompt(input).catch((err) => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @fileoverview Distinguish complete terminal focus/mouse reports from keyboard-bearing input. */
|
||||
const FOCUS_REPORT_LENGTH = 3;
|
||||
const X10_REPORT_LENGTH = 6;
|
||||
|
||||
function sgrMouseReportLength(rest: string): number {
|
||||
if (!rest.startsWith('\x1b[<')) return 0;
|
||||
|
||||
let offset = 3;
|
||||
for (let field = 0; field < 3; field += 1) {
|
||||
const digitStart = offset;
|
||||
while (offset < rest.length && rest.charCodeAt(offset) >= 48 && rest.charCodeAt(offset) <= 57) {
|
||||
offset += 1;
|
||||
}
|
||||
if (offset === digitStart) return 0;
|
||||
|
||||
if (field < 2) {
|
||||
if (rest[offset] !== ';') return 0;
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return rest[offset] === 'M' || rest[offset] === 'm' ? offset + 1 : 0;
|
||||
}
|
||||
|
||||
function x10MouseReportLength(rest: string): number {
|
||||
if (!rest.startsWith('\x1b[M') || rest.length < X10_REPORT_LENGTH) return 0;
|
||||
|
||||
const button = rest.charCodeAt(3);
|
||||
const x = rest.charCodeAt(4);
|
||||
const y = rest.charCodeAt(5);
|
||||
if (button < 32 || button > 255 || x < 33 || x > 255 || y < 33 || y > 255) return 0;
|
||||
|
||||
return X10_REPORT_LENGTH;
|
||||
}
|
||||
|
||||
function terminalReportLengthAt(data: string, offset: number): number {
|
||||
const rest = data.slice(offset);
|
||||
if (rest.startsWith('\x1b[I') || rest.startsWith('\x1b[O')) return FOCUS_REPORT_LENGTH;
|
||||
|
||||
const sgrLength = sgrMouseReportLength(rest);
|
||||
if (sgrLength > 0) return sgrLength;
|
||||
|
||||
return x10MouseReportLength(rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether terminal input contains any keyboard-bearing bytes.
|
||||
*
|
||||
* Returns false only when every byte is consumed as an allowlisted mouse or
|
||||
* focus report. Unknown, malformed, mixed, and empty input fails closed.
|
||||
*/
|
||||
export function isKeyboardTerminalInput(data: string): boolean {
|
||||
if (data.length === 0) return true;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const reportLength = terminalReportLengthAt(data, offset);
|
||||
if (reportLength === 0) return true;
|
||||
offset += reportLength;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+121
-9
@@ -422,6 +422,46 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from every pane tmux listed, which directory each tmux session's pane
|
||||
* is in now (`#{pane_current_path}`). Exported for unit testing.
|
||||
*
|
||||
* This is what a relative path printed in the terminal is relative TO: a
|
||||
* session's `workingDir` is fixed at create time and never follows a `cd`.
|
||||
*
|
||||
* - Only sessions with EXACTLY ONE pane answer, for the reason
|
||||
* {@link derivePaneExits} gives: a hand-split session has no single "the"
|
||||
* pane to speak for it.
|
||||
* - Only absolute paths count. A blank field is a tmux that could not read the
|
||||
* process cwd: usually a pane whose program exited under `remain-on-exit`
|
||||
* (tmux 3.4 then reports it blank on every read), otherwise a read that
|
||||
* failed that tick. Either way the directory is unknown, not gone, so a
|
||||
* single-pane session with a blank field keeps its entry from `previous`.
|
||||
* A session split into several panes, or one the read no longer lists, gets
|
||||
* no entry: those are authoritative absences, not unanswered questions.
|
||||
*
|
||||
* The launch directory is NOT filtered here (unlike the rediscovery path): a
|
||||
* remote or docker pane that never leaves it is refused by `Session.setPaneCwd()`,
|
||||
* and a local pane reporting it really is there.
|
||||
*/
|
||||
export function derivePaneCurrentPaths(
|
||||
rows: PaneRow[],
|
||||
previous: ReadonlyMap<string, string> = new Map()
|
||||
): Map<string, string> {
|
||||
const panesPerSession = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||
}
|
||||
const paths = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||
const path = row.currentPath ?? previous.get(row.sessionName);
|
||||
if (!path || !path.startsWith('/')) continue;
|
||||
paths.set(row.sessionName, path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||
* for unit testing.
|
||||
@@ -984,6 +1024,20 @@ export function formatCursorRestore(geometry: PaneCursorGeometry): string {
|
||||
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the output of `capture-pane -p \; display-message -p '<cursor format>'`
|
||||
* into the captured rows and the cursor line, which tmux prints last. Null unless
|
||||
* that last line has the cursor query's shape (four leading integers), so a query
|
||||
* that printed nothing can never have the frame's own last row taken for it.
|
||||
*/
|
||||
export function splitFrameAndCursor(output: string): { frame: string; cursor: string } | null {
|
||||
const body = output.endsWith('\n') ? output.slice(0, -1) : output;
|
||||
const split = body.lastIndexOf('\n');
|
||||
const cursor = body.slice(split + 1);
|
||||
if (!/^\d+ \d+ \d+ \d+(?: |$)/.test(cursor)) return null;
|
||||
return { frame: body.slice(0, split + 1), cursor };
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
@@ -1991,6 +2045,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
||||
/**
|
||||
* muxName -> `#{pane_current_path}` from the last authoritative pane read
|
||||
* ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
|
||||
* so it is bounded by the panes on the socket. Like {@link paneExits} it holds
|
||||
* the raw reading; `Session.setPaneCwd()` decides which sessions may use it.
|
||||
*/
|
||||
private paneCurrentPaths: Map<string, string> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
@@ -3493,6 +3554,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory the session's pane was in at the last authoritative pane
|
||||
* read, or `undefined` when unknown. Costs nothing: it reads the map the
|
||||
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
|
||||
* per click. A failed or empty read keeps the previous answers, and so does
|
||||
* a blank `#{pane_current_path}` on a single-pane session. After a successful
|
||||
* read, `undefined` means that read listed no single directory for the
|
||||
* session: it was split into several panes or is no longer on the socket.
|
||||
*/
|
||||
getPaneCurrentPath(muxName: string): string | undefined {
|
||||
return this.paneCurrentPaths.get(muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||
@@ -3554,6 +3628,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
// The directory reading rides the same read. It is applied before the
|
||||
// generation check below: a respawn invalidates an exit observation, but the
|
||||
// pane's directory at read time is still the best answer there is. A blank
|
||||
// field on a single-pane session carries the previous answer forward.
|
||||
this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths);
|
||||
// A pane was respawned or killed while this read was out, so what it saw is
|
||||
// already history. Dropping it is what stops a freshly respawned pane from
|
||||
// being republished as exited.
|
||||
@@ -3983,7 +4062,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
||||
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
||||
*/
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
async sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise<boolean> {
|
||||
if (options?.beforeDispatch && input !== '\r') return false;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error(
|
||||
@@ -3996,7 +4076,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// inputRunner() so a test subclass can record what WOULD reach tmux.
|
||||
const run = this.inputRunner();
|
||||
if (!run) {
|
||||
return true;
|
||||
return options?.beforeDispatch?.() ?? true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -4050,7 +4130,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
|
||||
);
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
// Enter only: no await between the guard and dispatch.
|
||||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||||
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
|
||||
}
|
||||
|
||||
@@ -4279,7 +4360,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
const exec = this.captureRunner();
|
||||
if (!exec) return '';
|
||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||
if (!target) {
|
||||
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
|
||||
@@ -4317,9 +4399,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
execOpts.maxBuffer =
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
const cursorQuery = `display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`;
|
||||
const captureCommand = `${this.tmux()} ${captureFlags} -t ${shellescape(target)}`;
|
||||
// The visible frame and its caret come from ONE tmux invocation
|
||||
// (the capture, `\;`, then the cursor query): tmux runs a command list back to back
|
||||
// in its command queue, with no pane output parsed between the two, so the
|
||||
// caret belongs to the frame. Two separate calls raced the CLI's repaint: a
|
||||
// CLI that redraws its composer a few ms after input arrives (codex) could be
|
||||
// captured before the redraw and its caret queried after it, and guarded
|
||||
// stage then refused a frame the keys had already reached. Only output that
|
||||
// splitFrameAndCursor() cannot split (a last line that is not the query's)
|
||||
// falls back to the two separate calls, which is what this always did. A call
|
||||
// that THREW (a hung tmux timing out) goes to the catch below like any other
|
||||
// capture failure: retrying it would block the event loop for a second timeout
|
||||
// on every `GET .../terminal` load.
|
||||
const combined = fullHistory
|
||||
? null
|
||||
: splitFrameAndCursor(
|
||||
exec(`${captureCommand} \\; ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
|
||||
);
|
||||
const rawCapture = joinScrollbackAndFrame(
|
||||
scrollbackOverride,
|
||||
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
|
||||
combined?.frame ?? exec(captureCommand, execOpts),
|
||||
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
|
||||
);
|
||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
||||
@@ -4327,10 +4428,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// appended, and those two have to agree: trailing blank rows are only safe
|
||||
// to keep when a move follows to put the caret back above them.
|
||||
const geometry = queryPaneCursor(() =>
|
||||
execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
combined
|
||||
? combined.cursor
|
||||
: exec(`${this.tmux()} ${cursorQuery}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })
|
||||
);
|
||||
// Report the size the pane was really drawing at. The visible-frame path
|
||||
// below addresses every row absolutely, so a consumer whose terminal is
|
||||
@@ -4396,6 +4496,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest).
|
||||
* Split out like {@link inputRunner} so a test subclass can count and script the
|
||||
* calls a capture makes.
|
||||
*/
|
||||
protected captureRunner():
|
||||
| ((cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string)
|
||||
| null {
|
||||
if (IS_TEST_MODE) return null;
|
||||
return (cmd, opts) => execSync(cmd, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the active pane for a tmux session.
|
||||
*
|
||||
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user