mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 06:59:33 +01:00
Compare commits
226
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba5134174 | ||
|
|
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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
dist/
|
||||
Generated
+3134
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
[package]
|
||||
name = "codeman-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
description = "Codeman Desktop: a small native window for a Codeman server"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "codeman-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Window + event loop (tao), the system web view (wry: WKWebView on macOS,
|
||||
# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is
|
||||
# built on these three; using them directly keeps the app small and gives us
|
||||
# `load_url_with_headers`, which Tauri's navigate() does not have (see README).
|
||||
tao = "0.37"
|
||||
wry = { version = "0.57", features = ["devtools"] }
|
||||
muda = "0.21"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||
ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
|
||||
# content size (src/macos.rs). The versions wry already pulls in, so no new
|
||||
# crate is added.
|
||||
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
|
||||
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSDate",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
"NSURL",
|
||||
"NSURLAuthenticationChallenge",
|
||||
"NSURLCredential",
|
||||
"NSURLProtectionSpace",
|
||||
"NSURLRequest",
|
||||
"NSURLResponse",
|
||||
"NSURLSession",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSAlert",
|
||||
"NSApplication",
|
||||
"NSButton",
|
||||
"NSControl",
|
||||
"NSResponder",
|
||||
"NSTextField",
|
||||
"NSView",
|
||||
"NSWindow",
|
||||
] }
|
||||
objc2-web-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"block2",
|
||||
"objc2-app-kit",
|
||||
"WKFrameInfo",
|
||||
"WKNavigationDelegate",
|
||||
"WKSecurityOrigin",
|
||||
"WKUIDelegate",
|
||||
"WKWebView",
|
||||
"WKWebsiteDataRecord",
|
||||
"WKWebsiteDataStore",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,272 @@
|
||||
# Codeman Desktop
|
||||
|
||||
A small native app that opens Codeman in its own window. Double-click it, and
|
||||
it runs Codeman on this Mac (installing it first if it is not there) or
|
||||
connects to Codeman on another machine (installing it there over SSH if
|
||||
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
|
||||
|
||||
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||
Windows and Linux later.
|
||||
|
||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
|
||||
No bundled browser and no Node in the app.
|
||||
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
|
||||
|
||||
## Why it is cheap
|
||||
|
||||
Codeman is already a web app, so the desktop app is a native window around the
|
||||
operating system's own web view. An Electron build of the same thing would ship
|
||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||
cannot do.
|
||||
|
||||
## What it does (v0.1)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
|
||||
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. |
|
||||
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
|
||||
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
|
||||
Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older
|
||||
toolchain) and the Xcode Command Line Tools (`xcode-select --install`).
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
cargo test # unit tests
|
||||
./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture
|
||||
./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-<version>.dmg
|
||||
open dist/Codeman.app
|
||||
```
|
||||
|
||||
The script builds with `--locked` against the committed `Cargo.lock`, targets
|
||||
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
|
||||
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
|
||||
cargo config's `build.target-dir` work.
|
||||
|
||||
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
|
||||
in the Keychain under *Codeman Desktop*, one item per username and server. A
|
||||
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
|
||||
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
|
||||
to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten
|
||||
names are taken, or the file cannot be moved, the app starts with default
|
||||
settings and saves nothing, which leaves the file where it is. When a server's
|
||||
username changes (a change of letter case counts), or a server drops off the
|
||||
recent list (it keeps 8), the Keychain item of the old username is deleted.
|
||||
|
||||
## Decisions, and why
|
||||
|
||||
**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates.
|
||||
The deciding detail is login: Codeman's password protection is HTTP Basic, and
|
||||
a WKWebView shows no login dialog of its own (wry 0.57 implements no
|
||||
authentication-challenge handler). So the app sends the `Authorization` header
|
||||
itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's
|
||||
`navigate()` takes only a URL), and from then on answers the HTTP Basic
|
||||
challenges of Codeman's own pages with the current login. Codeman keeps its
|
||||
session cookies in memory, so after a server restart that answer is what keeps
|
||||
the page logged in. Using the crates directly is also smaller.
|
||||
|
||||
**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds
|
||||
the JavaScript dialog methods and the authentication-challenge method to the
|
||||
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
|
||||
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
|
||||
|
||||
- Codeman shows content it did not write on its own origin: rendered HTML files
|
||||
(`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app
|
||||
shell (the dashboard and a session pop-out) may use `window.CodemanHost`.
|
||||
That is an allowlist on the raw path, and a path holding any `%` fails it, so
|
||||
an encoded spelling or a route Codeman adds later stays out.
|
||||
- An app window opens only for Codeman's own pop-out pages under the server's
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, and
|
||||
`api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
app's own. Such a window does not take keyboard focus, and these windows
|
||||
have a budget of their own (3 at once, then 1 per second), apart from the
|
||||
one links draw on. Any other page on the server's origin is refused rather
|
||||
than sent to the browser: a top-level `/webview/` load would escape the web
|
||||
tab's iframe sandbox in either place, so it never opens anywhere.
|
||||
`window.open('')` is refused too, since WebKit's blank window carries none of
|
||||
the app's handlers.
|
||||
- wry's navigation handler sees every frame and does not say which one. So a
|
||||
navigation to another site is blocked rather than opened in the browser;
|
||||
otherwise a page full of external iframes could open browser tabs. A real
|
||||
link click is sent out by the injected script in the main frame only.
|
||||
- Only `http(s)` URLs ever reach the default browser, at most 3 at once and
|
||||
then 1 per second. macOS lets a page call `window.open` without a click, so
|
||||
this keeps a script from opening a browser tab for every call.
|
||||
- The `Authorization` header is only sent to URLs under the server's base path.
|
||||
The login answers a Basic challenge only when it is Codeman's own: the
|
||||
server's scheme, host and port (never a proxy's), a URL below the base path,
|
||||
and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which
|
||||
relay 401s from content Codeman did not write. Only such a challenge can
|
||||
send the user back to the connect screen. WebKit is handed the login without
|
||||
persistence, so it keeps no copy: every challenge gets the current login,
|
||||
and one from before a Switch Server is never replayed.
|
||||
- Each connect starts by clearing the app's cookies (never local storage).
|
||||
Codeman checks its session cookie before the `Authorization` header, so an
|
||||
earlier user's cookie would otherwise win over the new login.
|
||||
- Microphone and camera requests prompt per page. wry's default without a
|
||||
handler is to grant them to every frame, sandboxed previews included.
|
||||
- The bundled connect page keeps working until a server page commits in the
|
||||
main window (it is still the live page while the server loads), and only
|
||||
the main window may talk to it.
|
||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||
shell text.
|
||||
|
||||
**The installer runs in Terminal, not inside the app.** `install.sh` asks for
|
||||
the administrator password on a terminal (Homebrew, its sudo keep-alive), the
|
||||
Xcode tools arrive as a system dialog, and ssh asks about host keys and
|
||||
passwords on a terminal too, so an install without one fails on the first
|
||||
fresh Mac. The app writes a short script to the per-user temporary folder
|
||||
(mode 0700, created fresh, deleted by the script as it starts) and opens it
|
||||
with `open -b com.apple.Terminal`, which needs no permission; scripting
|
||||
Terminal with osascript would ask for Automation access. The script prints
|
||||
what it runs. A This Mac install downloads the installer to a temporary file
|
||||
and runs it from there, so the installer's input is the terminal. Everything in
|
||||
the scripts is built and unit-tested in `src/setup.rs`; the page sends only
|
||||
which install it wants, whether other devices should reach it, and the SSH
|
||||
login, which is checked before it is quoted.
|
||||
|
||||
**This Mac's own login comes from where Codeman keeps it.** A Codeman
|
||||
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
|
||||
`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent`
|
||||
read). The app reads the same places, so a password-protected Codeman on this
|
||||
Mac opens without typing the password. The username and password always come
|
||||
from the same place, since the server reads its username from its own
|
||||
environment. The login goes only to `127.0.0.1` on that Codeman's port, and
|
||||
only while `lsof` shows one of your own Codeman processes holding that port:
|
||||
with Codeman stopped, whatever else listens there never gets it.
|
||||
|
||||
**Never a second server on the same sessions.** Two Codeman servers on one
|
||||
data directory and tmux socket attach to each other's live sessions, and
|
||||
`codeman web -d` only notices a server it started itself or one at the same
|
||||
address. So the app looks for running Codeman servers of this user (`ps`)
|
||||
before it offers or runs a start, and opens the one it finds instead.
|
||||
|
||||
**Closing the main window quits the app.** Codeman's sessions keep running on
|
||||
the server, so nothing is lost, and reopening takes about a second.
|
||||
|
||||
**One reachability probe per user action, and one to check a refused login.**
|
||||
Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
restart, each request that arrives without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
machine, serve Codeman through `tailscale serve`
|
||||
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse
|
||||
proxy with a real certificate. Marking the certificate as trusted in
|
||||
Keychain Access can only help at `https://localhost` or `https://127.0.0.1`,
|
||||
the only names Codeman's certificate carries, and may not work even there.
|
||||
The connect screen says so when it happens.
|
||||
- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain
|
||||
knows the app that saved a password by its code signature, and an ad-hoc
|
||||
signature changes with every build and update. So each new build asks once
|
||||
for access to the saved passwords: choose *Always Allow*. All Keychain work
|
||||
runs on one background thread, so the prompt does not freeze the window
|
||||
while it waits for an answer. To keep one identity across builds, sign with
|
||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||
is enough). Microphone and camera permissions are asked again for the same
|
||||
reason.
|
||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. Shipping it to other people needs a Developer
|
||||
ID signature and notarization:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
|
||||
then `xcrun notarytool submit` and `xcrun stapler staple`.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
address on the local network (a LAN IP, a `.local` name) makes macOS ask
|
||||
whether Codeman may use the local network, and that first attempt can fail
|
||||
while the question is open: connect again after answering. If it was denied,
|
||||
turn Codeman on in System Settings > Privacy & Security > Local Network. A
|
||||
server started with *Start Codeman on this Mac* counts as part of the app
|
||||
here, so the same answer covers what it reaches on the local network (ssh to
|
||||
LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the
|
||||
app quits.
|
||||
- **Subagent transcript windows do not open in the app.** Codeman writes that
|
||||
view into a blank `window.open('')` window, which the app refuses (see
|
||||
Decisions), and shows a toast instead. A Codeman without the null check in
|
||||
`viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to
|
||||
load transcript* there. Open the transcript from a browser tab.
|
||||
- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman
|
||||
window waits for it. A page that calls `alert()` in a loop (a rendered HTML
|
||||
preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`).
|
||||
- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS
|
||||
11.3 or later (downloads go through WKDownload, which arrived in 11.3), but
|
||||
Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit
|
||||
that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4
|
||||
or later installed. On an older WebKit the connect screen says so and does
|
||||
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
|
||||
which the same requirement covers.
|
||||
- **Links in rendered HTML and web tabs do not open.** A link to another site
|
||||
inside a rendered HTML preview or a web tab does nothing, in the preview
|
||||
overlay, and for a rendered HTML file in its pop-out window too. The app
|
||||
sends links out only from Codeman's own pages, and blocks navigation to
|
||||
other sites in every frame (see Decisions).
|
||||
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
|
||||
only Codeman's own login, and WebKit shows no dialog for any other, so a
|
||||
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
|
||||
- **Downloads from previews and web tabs are saved without asking.** Rendered
|
||||
HTML files and web tabs are allowed to start downloads, and the app saves
|
||||
every download to `~/Downloads` without a prompt. Asking first for those
|
||||
frames is not done yet.
|
||||
- **No desktop notifications yet.** WKWebView has no Web Notification API.
|
||||
Native notifications are the first item on the list below.
|
||||
- **No service worker.** Codeman works without it; it is only an offline cache.
|
||||
- **Installing from the app is macOS only so far**, and installing on another
|
||||
machine needs ssh access to it and `curl` or `wget` there. The app does not
|
||||
know when a remote install is done or which address it chose: the installer
|
||||
prints the address, and you connect to it under Another machine.
|
||||
- **A This Mac install always sets Codeman up as a LaunchAgent** that starts
|
||||
at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`;
|
||||
to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`.
|
||||
- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a
|
||||
credential store (Credential Manager, Secret Service) and installers. The
|
||||
rest of the code is platform-neutral.
|
||||
|
||||
## Ideas for next
|
||||
|
||||
- Native notifications and a Dock badge with the number of sessions waiting
|
||||
for you (tao has `set_badge_label`; Codeman already tracks which sessions need input).
|
||||
- A global hotkey that brings Codeman to the front from anywhere.
|
||||
- A menu bar icon showing session states.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
@@ -0,0 +1,897 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Codeman</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a0a0a;
|
||||
--panel: #121316;
|
||||
--line: #23252b;
|
||||
--line-strong: #30333b;
|
||||
--text: #e8eaed;
|
||||
--muted: #8b9099;
|
||||
--accent: #60a5fa;
|
||||
--accent-2: #3b82f6;
|
||||
--bad: #f87171;
|
||||
--warn: #fbbf24;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
main { width: min(440px, calc(100vw - 48px)); }
|
||||
header { text-align: center; margin-bottom: 26px; }
|
||||
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
|
||||
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
|
||||
header p { margin: 4px 0 0; color: var(--muted); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
}
|
||||
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
|
||||
input[type=text], input[type=password], input[type=url] {
|
||||
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
|
||||
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
|
||||
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
||||
}
|
||||
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
|
||||
.row { display: flex; gap: 10px; }
|
||||
.row > * { flex: 1; }
|
||||
.gap { height: 14px; }
|
||||
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
#recent:empty { display: none; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 2px; max-width: 100%;
|
||||
border: 1px solid var(--line-strong); border-radius: 999px; background: #0f1013;
|
||||
color: var(--text); font-size: 12px;
|
||||
}
|
||||
.chip button { all: unset; cursor: default; padding: 4px 4px 4px 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
|
||||
.chip button.x { padding: 4px 9px 4px 4px; color: var(--muted); }
|
||||
.chip button.x:hover { color: var(--bad); }
|
||||
.chip:hover { border-color: var(--accent-2); }
|
||||
.chip button:disabled { opacity: 0.5; }
|
||||
#login[hidden] { display: none; }
|
||||
#login { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.check { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin-top: 10px; }
|
||||
.check input { accent-color: var(--accent-2); margin: 0; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||
button.primary, button.secondary {
|
||||
height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default;
|
||||
}
|
||||
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); }
|
||||
button.primary:disabled { opacity: 0.55; }
|
||||
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
|
||||
button.secondary:hover { border-color: var(--accent-2); }
|
||||
button.secondary:disabled { opacity: 0.55; }
|
||||
button[hidden] { display: none; }
|
||||
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
|
||||
#status.bad { color: var(--bad); }
|
||||
#status.warn { color: var(--warn); }
|
||||
#status.ok { color: var(--ok); }
|
||||
#status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; }
|
||||
#suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#suggest[hidden] { display: none; }
|
||||
#webkit { margin: 0 0 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,0.4); border-radius: 9px; background: rgba(251,191,36,0.08); color: var(--warn); font-size: 13px; -webkit-user-select: text; user-select: text; }
|
||||
#webkit[hidden] { display: none; }
|
||||
.spinner { width: 14px; height: 14px; flex: none; margin-top: 2px; border-radius: 50%; border: 2px solid rgba(96,165,250,0.25); border-top-color: var(--accent); animation: spin 0.8s linear infinite; }
|
||||
.spinner[hidden] { display: none; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
|
||||
.prefs { margin-top: 16px; }
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; }
|
||||
.tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button[aria-selected="true"] { background: #1c1f26; color: var(--text); box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 1px 3px rgba(0,0,0,0.45); }
|
||||
.tabs button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; }
|
||||
[role=tabpanel][hidden] { display: none; }
|
||||
.local { display: flex; gap: 12px; align-items: flex-start; min-height: 42px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; flex: none; background: #4b5059; }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 4px rgba(74,222,128,0.14); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.busy { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } }
|
||||
#local-title { font-weight: 600; }
|
||||
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
|
||||
#local-network-row[hidden] { display: none; }
|
||||
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||
#elsewhere summary::-webkit-details-marker { display: none; }
|
||||
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
|
||||
#elsewhere[open] summary::before { transform: rotate(90deg); }
|
||||
#elsewhere summary:hover { color: var(--text); }
|
||||
#elsewhere .field { margin-top: 12px; }
|
||||
.inline { display: flex; gap: 8px; align-items: center; }
|
||||
.inline > input { flex: 1; min-width: 0; }
|
||||
.inline > button { flex: none; }
|
||||
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
|
||||
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.statusrow #status { flex: 1; }
|
||||
.statusrow #cancel { margin-top: 10px; }
|
||||
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
|
||||
footer a { color: #7c8491; text-decoration: none; cursor: default; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img src="icon.png" alt="">
|
||||
<h1>Codeman</h1>
|
||||
<p>Run Codeman on this Mac, or connect to another machine</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="tabs" role="tablist" aria-label="Where Codeman runs">
|
||||
<button type="button" role="tab" id="tab-local" aria-controls="pane-local" aria-selected="true">This Mac</button>
|
||||
<button type="button" role="tab" id="tab-remote" aria-controls="form" aria-selected="false" tabindex="-1">Another machine</button>
|
||||
</div>
|
||||
|
||||
<p id="webkit" role="alert" hidden>Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</p>
|
||||
|
||||
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
|
||||
<div class="local">
|
||||
<span class="dot busy" id="local-dot"></span>
|
||||
<div>
|
||||
<div id="local-title">Looking for Codeman on this Mac…</div>
|
||||
<div class="sub" id="local-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check" id="local-network-row" hidden><input id="local-network" type="checkbox"> Also reach it from my phone and other computers (the installer asks how)</label>
|
||||
<div class="actions">
|
||||
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
|
||||
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
|
||||
<button class="primary" id="local-install" type="button" hidden>Install Codeman on this Mac</button>
|
||||
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
|
||||
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
|
||||
<label class="field" for="url">Server address</label>
|
||||
<input id="url" type="text" spellcheck="false" autocapitalize="off"
|
||||
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
|
||||
<datalist id="recent-list"></datalist>
|
||||
<div id="recent" aria-label="Recent servers"></div>
|
||||
|
||||
<div id="login" hidden>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label class="field" for="username">Username</label>
|
||||
<input id="username" type="text" spellcheck="false" autocapitalize="off" placeholder="admin">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="password">Password</label>
|
||||
<input id="password" type="password" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
<label class="check"><input id="remember" type="checkbox" checked> Remember the password in the Keychain</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="connect" type="submit">Connect</button>
|
||||
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
|
||||
</div>
|
||||
|
||||
<details id="elsewhere">
|
||||
<summary>Codeman is not installed there yet?</summary>
|
||||
<label class="field" for="ssh">Install it over SSH</label>
|
||||
<div class="inline">
|
||||
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||||
</div>
|
||||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div class="statusrow">
|
||||
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
|
||||
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
|
||||
</div>
|
||||
<button class="secondary" id="suggest" type="button" hidden></button>
|
||||
|
||||
<div class="prefs">
|
||||
<label class="check"><input id="auto" type="checkbox"> Connect automatically when the app opens</label>
|
||||
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>⌃⌘F</kbd></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span id="version">Codeman Desktop</span> ·
|
||||
<a href="#" id="docs">Help</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Codeman's terminal (xterm 6) uses class static blocks, which WebKit parses
|
||||
// from Safari 16.4 on. An older WebKit rejects this whole script, so the flag
|
||||
// stays unset. This is its own script because the page's CSP has no
|
||||
// 'unsafe-eval', so new Function cannot run the test.
|
||||
void class { static { window.__codemanStaticBlocks = true; } };
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
// Codeman would not load in this web view: say so, never connect, and keep
|
||||
// Connect disabled. The page still answers the app (ready, preferences).
|
||||
var tooOld = window.__codemanStaticBlocks !== true;
|
||||
var els = {
|
||||
form: $('form'), url: $('url'), list: $('recent-list'), recent: $('recent'),
|
||||
login: $('login'), username: $('username'), password: $('password'), remember: $('remember'),
|
||||
connect: $('connect'), cancel: $('cancel'), start: $('start'), suggest: $('suggest'),
|
||||
status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
|
||||
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
|
||||
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
|
||||
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
|
||||
localNetworkRow: $('local-network-row'), localNetwork: $('local-network'),
|
||||
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
|
||||
localStop: $('local-stop'), localCheck: $('local-check'),
|
||||
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
|
||||
};
|
||||
var manual = /[?&]manual=1/.test(location.search);
|
||||
// startUrl: the address the user asked to start Codeman for ('' when this
|
||||
// page did not ask, e.g. it was reloaded while a start was running).
|
||||
// suggestion: the address the suggest button connects to.
|
||||
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
|
||||
// This Mac status from the app. wait: set while an install runs in
|
||||
// Terminal and this page polls for the server it brings up.
|
||||
var state = {
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
|
||||
stoppedWaiting: false
|
||||
};
|
||||
// While an install runs: how often to look, how long a finished install may
|
||||
// take to answer before Start is offered, and when to stop looking.
|
||||
var WAIT_POLL_MS = 3000;
|
||||
var WAIT_START_GRACE_MS = 90 * 1000;
|
||||
var WAIT_MAX_MS = 30 * 60 * 1000;
|
||||
|
||||
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
||||
|
||||
function setStatus(kind, text, detail) {
|
||||
els.status.className = kind || '';
|
||||
els.spinner.hidden = kind !== 'busy';
|
||||
els.statusText.textContent = text || '';
|
||||
if (detail) {
|
||||
var d = document.createElement('span');
|
||||
d.className = 'detail';
|
||||
d.textContent = detail;
|
||||
els.statusText.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
// One request at a time: every way to start one (Connect, a recent server,
|
||||
// the suggest button) is disabled while busy, never hidden, so the second
|
||||
// click of a double click cannot land on whatever moved into its place.
|
||||
// Cancel only where the native side can really cancel: a probe or a page
|
||||
// load, never a server start.
|
||||
function setBusy(busy, cancellable) {
|
||||
state.busy = busy;
|
||||
els.connect.disabled = busy || tooOld;
|
||||
els.suggest.disabled = busy || tooOld;
|
||||
els.cancel.hidden = !(busy && cancellable !== false);
|
||||
if (busy) els.start.hidden = true;
|
||||
disableChips();
|
||||
syncLocalButtons();
|
||||
}
|
||||
|
||||
// Forgetting a server stays possible when the WebKit is too old; connecting
|
||||
// to one does not.
|
||||
function disableChips() {
|
||||
Array.prototype.forEach.call(els.recent.querySelectorAll('button'), function (b) {
|
||||
b.disabled = state.busy || (tooOld && !b.classList.contains('x'));
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing to suggest when this WebKit cannot open Codeman at any address.
|
||||
function suggest(url) {
|
||||
if (tooOld) return;
|
||||
state.suggestion = url;
|
||||
els.suggest.textContent = 'Use ' + url;
|
||||
els.suggest.title = 'Connect to ' + url;
|
||||
els.suggest.hidden = false;
|
||||
}
|
||||
|
||||
function hideSuggestion() {
|
||||
state.suggestion = '';
|
||||
els.suggest.hidden = true;
|
||||
}
|
||||
|
||||
function host(url) {
|
||||
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
||||
}
|
||||
|
||||
// The address as the app normalizes it (policy::normalize_server_url):
|
||||
// http:// by default, https:// for a .ts.net name, no query or fragment, a
|
||||
// trailing slash. Only used to tell whether two addresses are one server.
|
||||
function normalize(raw) {
|
||||
var s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
if (s.indexOf('://') < 0) {
|
||||
var name = s.split(/[\/:]/)[0].toLowerCase();
|
||||
s = (/\.ts\.net$/.test(name) ? 'https' : 'http') + '://' + s;
|
||||
}
|
||||
try {
|
||||
var u = new URL(s);
|
||||
u.search = '';
|
||||
u.hash = '';
|
||||
if (!/\/$/.test(u.pathname)) u.pathname += '/';
|
||||
return u.href;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function sameServer(a, b) {
|
||||
var na = normalize(a);
|
||||
return na !== '' && na === normalize(b);
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
els.recent.textContent = '';
|
||||
els.list.textContent = '';
|
||||
state.servers.forEach(function (s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.url;
|
||||
els.list.appendChild(opt);
|
||||
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
var pick = document.createElement('button');
|
||||
pick.type = 'button';
|
||||
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||||
pick.title = 'Connect to ' + s.url;
|
||||
pick.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
choose(s);
|
||||
connect();
|
||||
});
|
||||
var x = document.createElement('button');
|
||||
x.type = 'button';
|
||||
x.className = 'x';
|
||||
x.textContent = '×';
|
||||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||||
chip.appendChild(pick);
|
||||
chip.appendChild(x);
|
||||
els.recent.appendChild(chip);
|
||||
});
|
||||
disableChips();
|
||||
}
|
||||
|
||||
function choose(s) {
|
||||
els.url.value = s.url;
|
||||
els.username.value = s.username || '';
|
||||
els.password.value = '';
|
||||
els.password.placeholder = s.hasSavedPassword ? 'saved in Keychain' : '';
|
||||
els.login.hidden = !s.username;
|
||||
}
|
||||
|
||||
// The saved server the address field names, if any, with its login.
|
||||
function matchSaved() {
|
||||
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
|
||||
if (match) choose(match);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (state.busy || tooOld) return;
|
||||
var url = els.url.value.trim();
|
||||
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
|
||||
state.lastUrl = url;
|
||||
setBusy(true);
|
||||
setStatus('busy', 'Connecting to ' + url + '…');
|
||||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||||
if (!els.login.hidden) {
|
||||
if (els.username.value.trim()) msg.username = els.username.value.trim();
|
||||
if (els.password.value) msg.password = els.password.value;
|
||||
}
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
els.login.hidden = false;
|
||||
if (!els.username.value) els.username.value = 'admin';
|
||||
els.password.focus();
|
||||
}
|
||||
|
||||
function hostName(url) {
|
||||
try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
var LOCAL_NETWORK = 'On macOS 15 and later the app may also need Local Network access: System Settings > Privacy & Security > Local Network, then turn on Codeman.';
|
||||
|
||||
var explain = {
|
||||
needsLogin: ['warn', 'This server asks for a login.', 'Codeman’s default username is admin; the password is CODEMAN_PASSWORD on the server.'],
|
||||
badCredentials: ['bad', 'Wrong username or password.', 'After 10 failed attempts Codeman locks logins from this address for 15 minutes.'],
|
||||
rateLimited: ['bad', 'Too many failed logins from this address.', 'If you know the password, enter it and connect: a single-user Codeman accepts the right password even now. Otherwise, or on a multi-user server, wait up to 15 minutes and try again.'],
|
||||
hostNotAllowed: ['bad', 'Codeman refused this host name.', 'Codeman only answers names it knows. Add this one to CODEMAN_ALLOWED_HOSTS on the server, or use its tailnet name or 127.0.0.1.'],
|
||||
refused: ['bad', 'Nothing is answering at this address.', ''],
|
||||
timeout: ['bad', 'The server did not answer within 5 seconds.', 'Check that the machine is awake and reachable (Tailscale connected, same network).'],
|
||||
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
|
||||
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
|
||||
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
|
||||
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.'],
|
||||
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
|
||||
};
|
||||
|
||||
// The same two at localhost or 127.0.0.1, the only names Codeman's
|
||||
// self-signed certificate carries, so the only ones where trusting it in
|
||||
// Keychain Access can help.
|
||||
var explainLoopback = {
|
||||
maybeHttps: 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address. The app’s web view refuses Codeman’s self-signed certificate, though: marking it as trusted in Keychain Access may work, or run Codeman without --https.',
|
||||
tlsUntrusted: 'This is usually Codeman’s self-signed certificate (codeman web --https), which the app’s web view refuses. Run Codeman without --https and use http://127.0.0.1:PORT instead. Marking the certificate as trusted in Keychain Access may also work.'
|
||||
};
|
||||
|
||||
function loopbackName(url) {
|
||||
var h = hostName(url);
|
||||
return h === 'localhost' || h === '127.0.0.1';
|
||||
}
|
||||
|
||||
function onProbeResult(msg) {
|
||||
setBusy(false);
|
||||
hideSuggestion();
|
||||
var o = msg.outcome || {};
|
||||
var e = explain[o.kind];
|
||||
if (o.kind === 'needsLogin' || o.kind === 'badCredentials' || o.kind === 'rateLimited') {
|
||||
// The login fields live on the Another machine tab.
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
} else if (state.tab === 'local') {
|
||||
// Opening the Codeman on this Mac failed: look at it again.
|
||||
checkLocal('login');
|
||||
}
|
||||
if (o.kind === 'notCodeman') {
|
||||
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
|
||||
} else if (o.kind === 'redirect') {
|
||||
var where = o.location ? 'It points to ' + o.location + '.' : '';
|
||||
if (o.suggested) {
|
||||
setStatus('warn', 'This address redirects to another one.', where + '\nCodeman seems to answer at ' + o.suggested + '.');
|
||||
suggest(o.suggested);
|
||||
} else {
|
||||
setStatus('bad', 'This address redirects somewhere else.', where + '\nThe app connects only to the address Codeman itself answers on. Check the address and, behind a proxy, the path Codeman is mounted under.');
|
||||
}
|
||||
} else if (o.kind === 'failed') {
|
||||
setStatus('bad', 'Could not connect.', o.detail || '');
|
||||
} else if (e) {
|
||||
var detail = e[2];
|
||||
if (o.kind === 'refused' && msg.canStart) detail = 'Codeman is not running on this Mac. Start it here, or check the port.';
|
||||
if (o.kind === 'rateLimited' && o.sentCredentials) detail = 'The saved or typed password may be wrong. ' + detail;
|
||||
if (o.kind === 'unresolvable' && /\.local\.?$/.test(hostName(msg.url))) {
|
||||
detail = 'Check the spelling, and that the machine is on and on the same network. ' + LOCAL_NETWORK;
|
||||
}
|
||||
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
|
||||
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
|
||||
setStatus(e[0], e[1], detail);
|
||||
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
|
||||
} else {
|
||||
setStatus('bad', 'Could not connect.');
|
||||
}
|
||||
els.start.hidden = !(o.kind === 'refused' && msg.canStart);
|
||||
}
|
||||
|
||||
function onStartResult(msg) {
|
||||
setBusy(false);
|
||||
// Connect only to the server this page asked to start; after a reload
|
||||
// mid-start, only to the one the address field shows.
|
||||
var asked = state.startUrl || els.url.value;
|
||||
state.startUrl = '';
|
||||
if (!msg.ok) { setStatus('bad', 'Codeman did not start.', msg.output || ''); return; }
|
||||
setStatus('ok', 'Codeman started.', msg.output || '');
|
||||
if (!msg.url || !sameServer(msg.url, asked)) return;
|
||||
if (!sameServer(msg.url, els.url.value)) { els.url.value = msg.url; matchSaved(); }
|
||||
connect();
|
||||
}
|
||||
|
||||
function isLocalUrl(url) {
|
||||
var h = hostName(normalize(url));
|
||||
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
if (tab !== 'local' && tab !== 'remote') return;
|
||||
var changed = state.tab !== tab;
|
||||
state.tab = tab;
|
||||
var local = tab === 'local';
|
||||
els.tabLocal.setAttribute('aria-selected', String(local));
|
||||
els.tabRemote.setAttribute('aria-selected', String(!local));
|
||||
els.tabLocal.tabIndex = local ? 0 : -1;
|
||||
els.tabRemote.tabIndex = local ? -1 : 0;
|
||||
els.paneLocal.hidden = !local;
|
||||
els.form.hidden = local;
|
||||
if (changed && !state.busy) {
|
||||
setStatus('', '');
|
||||
hideSuggestion();
|
||||
}
|
||||
if (local && !state.local && !state.localChecking) checkLocal('login');
|
||||
}
|
||||
|
||||
// quick: skip the login shell (used while an install runs, when only
|
||||
// "does it answer yet" matters).
|
||||
// How hard to look: 'quick' (files and processes, while an install runs),
|
||||
// 'login' (also the login shell; every automatic look) or 'deep' (also the
|
||||
// interactive profile, only on Check again).
|
||||
function checkLocal(how) {
|
||||
if (how !== 'quick') {
|
||||
state.localChecking = true;
|
||||
renderLocal();
|
||||
}
|
||||
var msg = { cmd: 'localStatus', quick: how === 'quick' };
|
||||
if (how === 'deep') msg.deep = true;
|
||||
post(msg);
|
||||
}
|
||||
|
||||
function syncLocalButtons() {
|
||||
els.localOpen.disabled = state.busy || tooOld;
|
||||
els.localStart.disabled = state.busy || tooOld;
|
||||
els.localInstall.disabled = state.busy || tooOld || state.opening;
|
||||
els.localCheck.disabled = state.busy || state.localChecking;
|
||||
els.sshInstall.disabled = state.busy || state.opening;
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
var s = state.local;
|
||||
var show = { open: false, start: false, install: false, stop: false, check: false, network: false };
|
||||
var dot = '';
|
||||
var title = '';
|
||||
var detail = '';
|
||||
if (state.wait) {
|
||||
dot = 'busy';
|
||||
title = 'Installing Codeman in Terminal…';
|
||||
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
|
||||
show.stop = true;
|
||||
} else if (state.localChecking || !s) {
|
||||
dot = 'busy';
|
||||
title = 'Looking for Codeman on this Mac…';
|
||||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
dot = 'busy';
|
||||
title = 'Codeman is being installed in Terminal.';
|
||||
detail = 'Finish the installer there, then Check again.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'runningElsewhere') {
|
||||
dot = 'warn';
|
||||
title = 'A Codeman server is already running on this Mac.';
|
||||
detail = 'It listens at ' + (s.elsewhere || []).join(', ') + ', which this app cannot open as ' + s.url +
|
||||
'. Connect to its address under Another machine. Starting another server here would take over its sessions.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'running') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
||||
show.open = true;
|
||||
} else if (s.state === 'needsLogin') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + ' asks for a login: Open asks for the password.';
|
||||
show.open = true;
|
||||
} else if (s.state === 'stopped') {
|
||||
title = 'Codeman is installed on this Mac, but not running.';
|
||||
if (s.daemon) {
|
||||
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web';
|
||||
} else if (/^https:/i.test(s.url)) {
|
||||
detail = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||||
} else {
|
||||
detail = s.service
|
||||
? 'It is set up to start when you log in, but nothing answers at ' + s.url + '. Start asks launchd to run it now.'
|
||||
: 'Start runs it in the background at ' + s.url + '.';
|
||||
show.start = true;
|
||||
}
|
||||
show.check = true;
|
||||
} else if (s.state === 'notInstalled') {
|
||||
title = 'Codeman is not installed on this Mac.';
|
||||
detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' +
|
||||
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.';
|
||||
show.install = true;
|
||||
show.network = true;
|
||||
show.check = true;
|
||||
} else if (!s.installed) {
|
||||
// Something else holds Codeman's port; a fresh install takes the next free one.
|
||||
var other = s.outcome || {};
|
||||
title = 'Codeman is not installed on this Mac.';
|
||||
detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') +
|
||||
', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.';
|
||||
show.install = true;
|
||||
show.network = true;
|
||||
show.check = true;
|
||||
} else {
|
||||
var o = s.outcome || {};
|
||||
dot = 'warn';
|
||||
if (o.kind === 'notCodeman') {
|
||||
title = 'Something else answers at ' + s.url;
|
||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to Codeman’s real address under Another machine.';
|
||||
} else {
|
||||
var e = explain[o.kind];
|
||||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||||
detail = (explainLoopback[o.kind]) || (e ? e[2] : (o.detail || ''));
|
||||
}
|
||||
show.check = true;
|
||||
}
|
||||
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
|
||||
els.localTitle.textContent = title;
|
||||
els.localDetail.textContent = detail;
|
||||
els.localOpen.hidden = !show.open;
|
||||
els.localStart.hidden = !show.start;
|
||||
els.localInstall.hidden = !show.install;
|
||||
els.localStop.hidden = !show.stop;
|
||||
els.localCheck.hidden = !show.check;
|
||||
els.localNetworkRow.hidden = !show.network;
|
||||
syncLocalButtons();
|
||||
}
|
||||
|
||||
function onLocalStatus(s) {
|
||||
state.localChecking = false;
|
||||
state.local = s || null;
|
||||
// An install from this app is still running (this page was shown again
|
||||
// while it ran): wait for it, unless the user stopped waiting.
|
||||
if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
}
|
||||
if (state.wait) {
|
||||
onWaitStatus(state.local || {});
|
||||
return;
|
||||
}
|
||||
renderLocal();
|
||||
}
|
||||
|
||||
// Point the Another machine form at `url` with only the login saved for it:
|
||||
// never the username or a typed password of the server the form showed
|
||||
// before, which connect() would otherwise send along.
|
||||
function useAddress(url) {
|
||||
els.url.value = url || '';
|
||||
els.login.hidden = true;
|
||||
els.username.value = '';
|
||||
els.password.value = '';
|
||||
els.password.placeholder = '';
|
||||
matchSaved();
|
||||
}
|
||||
|
||||
function openLocal() {
|
||||
if (state.busy || tooOld || !state.local) return;
|
||||
useAddress(state.local.url);
|
||||
connect();
|
||||
}
|
||||
|
||||
function startLocal() {
|
||||
if (state.busy || tooOld || !state.local) return;
|
||||
useAddress(state.local.url);
|
||||
state.startUrl = state.local.url;
|
||||
post({ cmd: 'startServer', url: state.startUrl });
|
||||
}
|
||||
|
||||
function installLocal() {
|
||||
if (state.busy || tooOld || state.opening) return;
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installLocal', network: els.localNetwork.checked });
|
||||
}
|
||||
|
||||
function scheduleWait() {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
||||
}
|
||||
|
||||
function endWait() {
|
||||
clearTimeout(state.waitTimer);
|
||||
state.wait = null;
|
||||
}
|
||||
|
||||
// One answer while an install runs. It stops at the first answer from a
|
||||
// server: a password-protected one counts every unauthenticated look as a
|
||||
// failed login.
|
||||
function portOf(url) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
function onWaitStatus(s) {
|
||||
var w = state.wait;
|
||||
var now = Date.now();
|
||||
if (s.installPort) w.port = s.installPort;
|
||||
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
||||
// where something else may answer when the install took another port.
|
||||
var ours = portOf(s.url) === w.port;
|
||||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
||||
endWait();
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
openLocal();
|
||||
return;
|
||||
}
|
||||
if (ours && s.state === 'other') {
|
||||
// Something answers that is not a working Codeman: say what.
|
||||
endWait();
|
||||
renderLocal();
|
||||
return;
|
||||
}
|
||||
if (s.installComplete && (s.state === 'stopped' || !ours)) {
|
||||
if (!w.finishedAt) {
|
||||
w.finishedAt = now;
|
||||
} else if (now - w.finishedAt > WAIT_START_GRACE_MS) {
|
||||
endWait();
|
||||
renderLocal();
|
||||
setStatus('warn', 'The install finished, but Codeman is not answering.', 'Start runs it now. If the installer reported a problem, its log is ~/.codeman/install.log.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (now - w.since > WAIT_MAX_MS) {
|
||||
endWait();
|
||||
renderLocal();
|
||||
setStatus('warn', 'Stopped waiting for the install.', 'Check again once the installer in Terminal is done.');
|
||||
return;
|
||||
}
|
||||
scheduleWait();
|
||||
}
|
||||
|
||||
function installRemote() {
|
||||
if (state.busy || state.opening) return;
|
||||
var target = els.ssh.value.trim();
|
||||
if (!target) {
|
||||
setStatus('bad', 'Enter the machine to install Codeman on, as user@host.');
|
||||
els.ssh.focus();
|
||||
return;
|
||||
}
|
||||
state.opening = true;
|
||||
syncLocalButtons();
|
||||
setStatus('busy', 'Opening Terminal…');
|
||||
post({ cmd: 'installRemote', target: target });
|
||||
}
|
||||
|
||||
window.__codemanDesktop = {
|
||||
onNative: function (msg) {
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
state.servers = msg.servers || [];
|
||||
state.startUrl = '';
|
||||
setBusy(false);
|
||||
hideSuggestion();
|
||||
setStatus('', '');
|
||||
els.start.hidden = true;
|
||||
els.auto.checked = !!msg.autoConnect;
|
||||
els.fullscreen.checked = !!msg.startFullscreen;
|
||||
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
|
||||
renderRecent();
|
||||
if (state.servers[0]) choose(state.servers[0]);
|
||||
// The tab of the server used last; This Mac on a first run.
|
||||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
|
||||
if (msg.starting) {
|
||||
setBusy(true, false);
|
||||
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
|
||||
} else if (msg.notice) {
|
||||
setStatus('warn', msg.notice);
|
||||
}
|
||||
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
|
||||
else if (state.tab === 'remote') els.url.focus();
|
||||
break;
|
||||
case 'servers':
|
||||
state.servers = msg.servers || [];
|
||||
renderRecent();
|
||||
break;
|
||||
case 'prefs':
|
||||
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
|
||||
if (typeof msg.autoConnect === 'boolean') els.auto.checked = msg.autoConnect;
|
||||
break;
|
||||
case 'probeResult':
|
||||
onProbeResult(msg);
|
||||
break;
|
||||
case 'opening':
|
||||
// Still busy: Cancel abandons the load and brings this page back.
|
||||
if (msg.passwordChange) {
|
||||
setStatus('ok', 'Opening Codeman…', 'Codeman will ask you for a new password for this account. Connect with the new password next time.');
|
||||
} else {
|
||||
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
setBusy(false);
|
||||
setStatus('bad', msg.message || 'Something went wrong.');
|
||||
if (msg.needsPassword) {
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
}
|
||||
break;
|
||||
case 'serverStarting':
|
||||
setBusy(true, false);
|
||||
setStatus('busy', 'Starting Codeman on this Mac…');
|
||||
break;
|
||||
case 'serverStartResult':
|
||||
onStartResult(msg);
|
||||
break;
|
||||
case 'localStatus':
|
||||
onLocalStatus(msg.status);
|
||||
break;
|
||||
case 'installStarted':
|
||||
state.opening = false;
|
||||
setStatus('', '');
|
||||
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
|
||||
selectTab('local');
|
||||
renderLocal();
|
||||
scheduleWait();
|
||||
break;
|
||||
case 'remoteInstallStarted':
|
||||
state.opening = false;
|
||||
syncLocalButtons();
|
||||
selectTab('remote');
|
||||
useAddress(msg.url);
|
||||
setStatus('ok', 'Terminal is installing Codeman on ' + msg.host + '.',
|
||||
'Answer the installer’s questions there. At the end it prints the address of Codeman on that machine: enter it above and connect (' + msg.url + ' is the usual one).');
|
||||
break;
|
||||
case 'installFailed':
|
||||
state.opening = false;
|
||||
syncLocalButtons();
|
||||
setStatus('bad', msg.message || 'Could not open Terminal.');
|
||||
break;
|
||||
case 'copied':
|
||||
setStatus('ok', 'Copied.', 'Paste it into a terminal on the other machine.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
|
||||
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
|
||||
els.start.addEventListener('click', function () {
|
||||
if (state.busy) return;
|
||||
state.startUrl = state.lastUrl || els.url.value.trim();
|
||||
post({ cmd: 'startServer', url: state.startUrl });
|
||||
});
|
||||
els.suggest.addEventListener('click', function () {
|
||||
if (state.busy || !state.suggestion) return;
|
||||
els.url.value = state.suggestion;
|
||||
matchSaved();
|
||||
connect();
|
||||
});
|
||||
els.auto.addEventListener('change', function () { post({ cmd: 'setAutoConnect', value: els.auto.checked }); });
|
||||
els.fullscreen.addEventListener('change', function () { post({ cmd: 'setStartFullscreen', value: els.fullscreen.checked }); });
|
||||
els.url.addEventListener('input', function () {
|
||||
matchSaved();
|
||||
els.start.hidden = true;
|
||||
hideSuggestion();
|
||||
});
|
||||
els.tabLocal.addEventListener('click', function () { selectTab('local'); });
|
||||
els.tabRemote.addEventListener('click', function () { selectTab('remote'); });
|
||||
[els.tabLocal, els.tabRemote].forEach(function (tab) {
|
||||
tab.addEventListener('keydown', function (ev) {
|
||||
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
|
||||
ev.preventDefault();
|
||||
var next = state.tab === 'local' ? 'remote' : 'local';
|
||||
selectTab(next);
|
||||
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
|
||||
});
|
||||
});
|
||||
els.localOpen.addEventListener('click', openLocal);
|
||||
els.localStart.addEventListener('click', startLocal);
|
||||
els.localInstall.addEventListener('click', installLocal);
|
||||
els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); });
|
||||
els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); });
|
||||
els.sshInstall.addEventListener('click', installRemote);
|
||||
// Enter in the SSH field installs; it must not submit the form (Connect).
|
||||
els.ssh.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); installRemote(); }
|
||||
});
|
||||
els.copy.addEventListener('click', function () { post({ cmd: 'copyInstallCommand' }); });
|
||||
$('docs').addEventListener('click', function (ev) { ev.preventDefault(); post({ cmd: 'openExternal', url: 'https://github.com/Ark0N/Codeman/wiki' }); });
|
||||
|
||||
if (tooOld) {
|
||||
els.webkit.hidden = false;
|
||||
setBusy(false);
|
||||
}
|
||||
post({ cmd: 'ready' });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getcodeman.desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>codeman-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<!-- 11.3 is where WKDownload arrived; wry saves files through it. Below
|
||||
that a Download link replaces the window or saves nothing.
|
||||
scripts/bundle-macos.sh builds for this version too. -->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT License, Codeman Contributors</string>
|
||||
<!-- Voice dictation and gesture control ask for these; without the
|
||||
strings macOS kills the app the first time a page requests them. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Codeman uses the microphone for voice dictation when you turn it on.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Codeman uses the camera for hand-gesture control when you turn it on.</string>
|
||||
<!-- macOS 15 asks before an app reaches the local network (a LAN address,
|
||||
a .local name), and shows this text in that question. A process the
|
||||
app starts counts as the app (Apple TN3179), so a server started with
|
||||
"Start Codeman on this Mac" runs under the same answer. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network.</string>
|
||||
<!-- App Transport Security would block plain http:// to a host NAME
|
||||
(my-mac.local, a LAN box). IP literals are exempt, which is why
|
||||
http://127.0.0.1 works without this. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Only used with a Developer ID (SIGN_IDENTITY=...), which turns on the
|
||||
hardened runtime; there, microphone and camera need these. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 120
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS.
|
||||
#
|
||||
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
|
||||
#
|
||||
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
|
||||
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
|
||||
# and later, try to open it once, then click Open Anyway in System Settings >
|
||||
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
|
||||
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
|
||||
# in /Applications. A Developer ID signature + notarization avoids all of this.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
UNIVERSAL=0
|
||||
DMG=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--universal) UNIVERSAL=1 ;;
|
||||
--dmg) DMG=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist,
|
||||
# so the two cannot disagree.
|
||||
MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist)
|
||||
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; }
|
||||
export MACOSX_DEPLOYMENT_TARGET
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Build with cargo and set BUILT to the executable cargo reports for
|
||||
# codeman-desktop. Asking cargo, rather than assuming ./target, keeps
|
||||
# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config
|
||||
# working, and a stale binary from an older build is never bundled.
|
||||
cargo_build() {
|
||||
local json
|
||||
json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics)
|
||||
BUILT=$(printf '%s\n' "$json" | awk '
|
||||
index($0, "\"reason\":\"compiler-artifact\"") &&
|
||||
index($0, "\"kind\":[\"bin\"]") &&
|
||||
index($0, "\"name\":\"codeman-desktop\"") &&
|
||||
match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) }
|
||||
END { print path }')
|
||||
if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then
|
||||
echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$UNIVERSAL" = 1 ]; then
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null
|
||||
cargo_build --target aarch64-apple-darwin
|
||||
ARM_BIN=$BUILT
|
||||
cargo_build --target x86_64-apple-darwin
|
||||
INTEL_BIN=$BUILT
|
||||
BIN="$WORK/codeman-desktop-universal"
|
||||
lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN"
|
||||
else
|
||||
cargo_build
|
||||
BIN=$BUILT
|
||||
fi
|
||||
|
||||
APP=dist/Codeman.app
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/codeman-desktop"
|
||||
sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||
|
||||
# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns
|
||||
ICONSET="$WORK/Codeman.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns"
|
||||
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP"
|
||||
else
|
||||
codesign --force --sign - "$APP"
|
||||
fi
|
||||
codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Files bundled into the binary: the connect page, its icon, and the script
|
||||
//! injected into every Codeman page (`window.CodemanHost`).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
|
||||
|
||||
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
|
||||
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
|
||||
|
||||
/// The connect page loads nothing from outside the app.
|
||||
const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:";
|
||||
|
||||
/// Serve `codemanapp://localhost/<path>`.
|
||||
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
|
||||
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
|
||||
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
|
||||
"/icon.png" => (ICON_PNG, "image/png"),
|
||||
_ => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Cow::Borrowed(&b"not found"[..]))
|
||||
.expect("static response");
|
||||
}
|
||||
};
|
||||
Response::builder()
|
||||
.header(CONTENT_TYPE, mime)
|
||||
.header("Content-Security-Policy", CSP)
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(Cow::Borrowed(body))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
/// Injected at document start into the MAIN frame of every page (wry's
|
||||
/// default), where it does nothing unless the page is http(s). It provides the
|
||||
/// native-wrapper bridge Codeman's frontend already looks for
|
||||
/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see
|
||||
/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app
|
||||
/// windows. `openWindow` answers false, posting nothing, for a URL the native
|
||||
/// side would drop (not http(s), or longer than its 4096-byte cap), so
|
||||
/// Codeman shows its own error instead of marking a session detached with no
|
||||
/// window behind it. Besides that it:
|
||||
///
|
||||
/// - turns a click on a same-origin download link (`download=true`, or a path
|
||||
/// ending in `/download`) into a real download by giving the link a
|
||||
/// `download` attribute. Codeman's file tree and attachment cards link to
|
||||
/// `file-raw?download=true` without one, and the server sends a type WebKit
|
||||
/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman
|
||||
/// window with the file, with no way back. This runs in the capture phase,
|
||||
/// before the file tree's own `stopPropagation`.
|
||||
/// - does the same for `window.open` of such a `download=true` URL (the
|
||||
/// attachment history), returning null instead of a window.
|
||||
/// - sends clicks on links to other sites to the default browser, after
|
||||
/// Codeman's own handlers have had their turn (bubble phase, skipped when
|
||||
/// one of them called preventDefault). Middle clicks and modifier clicks
|
||||
/// count too: a bare WKWebView has no new-tab gesture of its own, so they
|
||||
/// would do nothing.
|
||||
/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`),
|
||||
/// once per page load: the session cookie is gone, usually because the
|
||||
/// server restarted, and nothing in the page asks for the login again.
|
||||
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
|
||||
/// [`refused_script`].
|
||||
pub const HOST_SHIM: &str = r#"(function () {
|
||||
'use strict';
|
||||
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
|
||||
var MAX_URL = 4096;
|
||||
var post = function (msg) {
|
||||
try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; }
|
||||
};
|
||||
// An http(s) URL object, or null. fetch and window.open resolve against
|
||||
// the document base (Codeman sets <base href>), so those pass it.
|
||||
var httpUrl = function (raw, base) {
|
||||
try {
|
||||
var u = new URL(String(raw), base || location.href);
|
||||
return /^https?:$/.test(u.protocol) ? u : null;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
var sameOrigin = function (u) { return !!u && u.origin === location.origin; };
|
||||
var link = function (ev) {
|
||||
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
||||
return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null;
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'CodemanHost', {
|
||||
value: Object.freeze({
|
||||
desktop: true,
|
||||
openWindow: function (url) {
|
||||
var u = httpUrl(url);
|
||||
return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false;
|
||||
},
|
||||
closeWindow: function () { post({ cmd: 'closeWindow' }); },
|
||||
focusWindow: function () { post({ cmd: 'focusWindow' }); }
|
||||
})
|
||||
});
|
||||
|
||||
// The native side could not open a window: say so. `id` is a string only
|
||||
// when CodemanHost.openWindow asked for that session's pop-out; that session
|
||||
// goes back to the dashboard (a fresh channel object, so this window's own
|
||||
// channel hears it too). Anything else, null included, only shows the toast.
|
||||
Object.defineProperty(window, '__codemanDesktopRefused', {
|
||||
value: function (id) {
|
||||
try {
|
||||
if (window.app && typeof window.app.showToast === 'function') {
|
||||
window.app.showToast('Could not open a new window', 'error');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') {
|
||||
var ch = new BroadcastChannel('codeman-windows');
|
||||
ch.postMessage({ type: 'redocked', id: id });
|
||||
setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
var markDownload = function (ev) {
|
||||
if (ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) {
|
||||
a.setAttribute('download', '');
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', markDownload, true);
|
||||
window.addEventListener('auxclick', markDownload, true);
|
||||
|
||||
var lastMiddle = null;
|
||||
var openOutside = function (ev) {
|
||||
if (ev.defaultPrevented || ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (!u || sameOrigin(u)) return;
|
||||
ev.preventDefault();
|
||||
// Older WebKit sends a middle click as 'click', newer as 'auxclick'; one
|
||||
// that sent both must still open the link once.
|
||||
if (ev.button === 1) {
|
||||
if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return;
|
||||
lastMiddle = { a: a, t: ev.timeStamp };
|
||||
}
|
||||
post({ cmd: 'openExternal', url: u.href });
|
||||
};
|
||||
window.addEventListener('click', openOutside, false);
|
||||
window.addEventListener('auxclick', openOutside, false);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
if (typeof nativeOpen === 'function') {
|
||||
window.open = function open(url) {
|
||||
try {
|
||||
var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null;
|
||||
if (sameOrigin(u) && u.searchParams.get('download') === 'true') {
|
||||
var a = document.createElement('a');
|
||||
a.href = u.href;
|
||||
a.setAttribute('download', '');
|
||||
a.click();
|
||||
return null;
|
||||
}
|
||||
} catch (e) {}
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the promise fetch itself returned, so callers see exactly what
|
||||
// they saw before; the 401 check hangs off a side branch that swallows its
|
||||
// own errors and never reads the body.
|
||||
var expired = false;
|
||||
var nativeFetch = window.fetch;
|
||||
if (typeof nativeFetch === 'function') {
|
||||
var check = function (input, r) {
|
||||
if (expired || !r || r.status !== 401) return;
|
||||
var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input);
|
||||
if (!sameOrigin(httpUrl(raw, document.baseURI))) return;
|
||||
expired = true;
|
||||
post({ cmd: 'sessionExpired' });
|
||||
};
|
||||
window.fetch = function fetch(input) {
|
||||
var p = nativeFetch.apply(this, arguments);
|
||||
try {
|
||||
if (!expired && p && typeof p.then === 'function') {
|
||||
p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// A toast inside Codeman when a download finished (Codeman's own
|
||||
/// `showToast`; nothing happens on a page without it).
|
||||
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
|
||||
let text = if ok {
|
||||
format!("Downloaded {file_name} to Downloads")
|
||||
} else {
|
||||
format!("Download of {file_name} failed")
|
||||
};
|
||||
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
|
||||
let kind = if ok { "\"success\"" } else { "\"error\"" };
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
/// (pop-out limit reached, policy refused it, window or web view failed to
|
||||
/// build): the page shows an error toast. Does nothing on a page without the
|
||||
/// shim.
|
||||
///
|
||||
/// `session_id` is the decoded id of a session pop-out
|
||||
/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request,
|
||||
/// which comes from a trusted frame: that session then gets its tab back in
|
||||
/// the dashboard. Pass `None` for every other refusal, above all the ones a
|
||||
/// `window.open` or `target=_blank` from any frame caused, so a page cannot
|
||||
/// make the dashboard drop the detached state of a session that is still
|
||||
/// popped out.
|
||||
pub fn refused_script(session_id: Option<&str>) -> String {
|
||||
let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into());
|
||||
format!(
|
||||
"try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get(path: &str) -> Response<Cow<'static, [u8]>> {
|
||||
respond(
|
||||
Request::builder()
|
||||
.uri(format!("codemanapp://localhost{path}"))
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_page_and_icon_only() {
|
||||
let page = get("/index.html");
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
assert!(page.headers()["content-type"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("text/html"));
|
||||
assert!(page.headers().contains_key("content-security-policy"));
|
||||
assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop"));
|
||||
assert_eq!(get("/icon.png").status(), StatusCode::OK);
|
||||
assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_escapes_the_session_id() {
|
||||
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
|
||||
assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') "));
|
||||
assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#));
|
||||
assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}"));
|
||||
assert!(js.ends_with(" } catch (e) {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_without_a_session_passes_null() {
|
||||
assert_eq!(
|
||||
refused_script(None),
|
||||
"try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}"
|
||||
);
|
||||
assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_keeps_its_contract() {
|
||||
for needle in [
|
||||
"Object.freeze",
|
||||
"openWindow",
|
||||
"closeWindow",
|
||||
"focusWindow",
|
||||
"__codemanDesktopRefused",
|
||||
"sessionExpired",
|
||||
"'auxclick'",
|
||||
"MAX_URL = 4096",
|
||||
"typeof id === 'string' && id !== ''",
|
||||
] {
|
||||
assert!(HOST_SHIM.contains(needle), "{needle}");
|
||||
}
|
||||
// The refused hook takes the session id the native side resolved; it
|
||||
// never parses or decodes a URL to find one.
|
||||
assert!(!HOST_SHIM.contains("decodeURIComponent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_escapes_file_names() {
|
||||
let js = download_toast_script("a\"); alert(1); (\".txt", true);
|
||||
assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
//! The app's own settings: recent servers, two preferences and the window
|
||||
//! frame. Stored as JSON next to other per-app data
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_SERVERS: usize = 8;
|
||||
/// How many recovery copies of an unreadable config can sit side by side:
|
||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||
const ASIDE_SLOTS: usize = 10;
|
||||
const MIN_WIDTH: f64 = 480.0;
|
||||
const MIN_HEIGHT: f64 = 360.0;
|
||||
const MAX_EDGE: f64 = 20_000.0;
|
||||
|
||||
/// Height of a standard title bar, in points.
|
||||
const TITLE_BAR: f64 = 28.0;
|
||||
/// How far the outer top of the window lies above `WindowFrame::y`. On macOS
|
||||
/// `y` is the content area's top (tao's `with_position` puts the content rect
|
||||
/// there and the title bar above it); elsewhere `y` is already the outer top.
|
||||
const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 };
|
||||
/// How much of the title bar must be on a screen to grab it.
|
||||
const MIN_GRIP: f64 = 80.0;
|
||||
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
|
||||
/// bounds, so the menu bar and the Dock are inside them; these are the margins
|
||||
/// main.rs uses for the centred default frame.
|
||||
const FIT_MARGIN_X: f64 = 40.0;
|
||||
const FIT_MARGIN_Y: f64 = 80.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
/// Most recent first.
|
||||
pub servers: Vec<ServerEntry>,
|
||||
/// Connect to the most recent server at launch.
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
pub window: Option<WindowFrame>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
servers: Vec::new(),
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerEntry {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_saved_password: bool,
|
||||
}
|
||||
|
||||
/// The main window's position and inner size, in logical (point)
|
||||
/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS
|
||||
/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above
|
||||
/// it), elsewhere the window's outer top-left. Saving the outer corner on
|
||||
/// macOS and restoring it as the content corner made the window creep up by
|
||||
/// a title bar on every launch.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowFrame {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// A display's full bounds in logical coordinates, as tao reports them. This
|
||||
/// is the whole display, not the work area: the menu bar and the Dock are
|
||||
/// inside it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The frame clamped to sane sizes, or None when it holds junk.
|
||||
pub fn sanitized(self) -> Option<WindowFrame> {
|
||||
let vals = [self.x, self.y, self.width, self.height];
|
||||
if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE {
|
||||
return None;
|
||||
}
|
||||
Some(WindowFrame {
|
||||
width: self.width.clamp(MIN_WIDTH, MAX_EDGE),
|
||||
height: self.height.clamp(MIN_HEIGHT, MAX_EDGE),
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame made to fit the screens it will open on, or None when its
|
||||
/// title bar is on none of them (the caller then falls back to a default
|
||||
/// frame). An edge that no screen covers is pulled onto the screen that
|
||||
/// holds the title bar, shrinking the window when it is too big for it:
|
||||
/// a frame saved on an external display that is gone would otherwise
|
||||
/// open with its right edge and resize corner far off screen. An edge
|
||||
/// that lands on some screen stays put, so a window spanning two
|
||||
/// displays keeps its span. `min` is the window's minimum inner size.
|
||||
pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option<WindowFrame> {
|
||||
let home = *self.title_screen(screens)?;
|
||||
let top = self.outer_top();
|
||||
let outer_height = TITLE_BAR + self.height;
|
||||
let (x, width) = fit_axis(
|
||||
self.x,
|
||||
self.width,
|
||||
(
|
||||
covers_x(screens, self.x, top, top + outer_height),
|
||||
covers_x(screens, self.x + self.width, top, top + outer_height),
|
||||
),
|
||||
(home.x, home.x + home.width),
|
||||
FIT_MARGIN_X,
|
||||
min.0,
|
||||
);
|
||||
let (top, outer_height) = fit_axis(
|
||||
top,
|
||||
outer_height,
|
||||
(
|
||||
covers_y(screens, top, x, x + width),
|
||||
covers_y(screens, top + outer_height, x, x + width),
|
||||
),
|
||||
(home.y, home.y + home.height),
|
||||
FIT_MARGIN_Y - TITLE_BAR,
|
||||
min.1 + TITLE_BAR,
|
||||
);
|
||||
Some(WindowFrame {
|
||||
x,
|
||||
y: top + TITLE_ABOVE_Y,
|
||||
width,
|
||||
height: outer_height - TITLE_BAR,
|
||||
..*self
|
||||
})
|
||||
}
|
||||
|
||||
fn outer_top(&self) -> f64 {
|
||||
self.y - TITLE_ABOVE_Y
|
||||
}
|
||||
|
||||
/// The screen the title bar is on, or None when no screen has enough of
|
||||
/// it to grab (a display unplugged since the last run leaves the frame off
|
||||
/// screen). The middle of the title bar must be inside the screen and at
|
||||
/// least `MIN_GRIP` of the bar's width must overlap it; when several
|
||||
/// qualify, the one holding most of the bar wins.
|
||||
fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> {
|
||||
let title_y = self.outer_top() + TITLE_BAR / 2.0;
|
||||
screens
|
||||
.iter()
|
||||
.filter(|m| title_y >= m.y && title_y <= m.y + m.height)
|
||||
.map(|m| {
|
||||
let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x);
|
||||
(m, grip)
|
||||
})
|
||||
.filter(|(_, grip)| *grip >= MIN_GRIP)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(m, _)| m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the vertical edge at `x` that runs from
|
||||
/// `top` to `bottom`.
|
||||
fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y)
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the horizontal edge at `y` that runs
|
||||
/// from `left` to `right`.
|
||||
fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x)
|
||||
}
|
||||
|
||||
/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for
|
||||
/// `len`; `covered` says whether some screen holds each end; `home` is the
|
||||
/// title bar screen's range on this axis. An uncovered end is pulled onto
|
||||
/// `home`, and the window shrinks (leaving `margin`, never below `min`) when
|
||||
/// it does not fit; a covered end may stay where it is, on another display.
|
||||
fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) {
|
||||
if covered.0 && covered.1 {
|
||||
return (start, len);
|
||||
}
|
||||
let lo = if covered.0 { start.min(home.0) } else { home.0 };
|
||||
let hi = if covered.1 { (start + len).max(home.1) } else { home.1 };
|
||||
let len = len.min(hi - lo - margin).max(min);
|
||||
// `max` last: a window still too big for the screen keeps its start (the
|
||||
// left edge, or the title bar) on screen rather than its end.
|
||||
let start = start.min(hi - len).max(lo);
|
||||
(start, len)
|
||||
}
|
||||
|
||||
/// The first name an unreadable `desktop.json` can be moved to without
|
||||
/// replacing an earlier recovery copy: `desktop.json.corrupt`, then
|
||||
/// `desktop.json.corrupt.1` to `.9`, next to it.
|
||||
/// A name is free only when nothing at all is there: a file, a directory or
|
||||
/// a dangling symlink left at it is taken, and so is a name that cannot be
|
||||
/// checked. None when every name is taken.
|
||||
fn free_aside_name(path: &Path) -> Option<PathBuf> {
|
||||
(0..ASIDE_SLOTS)
|
||||
.map(|n| match n {
|
||||
0 => path.with_extension("json.corrupt"),
|
||||
n => path.with_extension(format!("json.corrupt.{n}")),
|
||||
})
|
||||
.find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Where the config lives, or None if the OS has no config directory.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json"))
|
||||
}
|
||||
|
||||
/// Read the config, and say whether saving back to `path` is safe this
|
||||
/// run. A missing file is the default. A file that is there but cannot
|
||||
/// be read (not UTF-8, no permission, a directory, an I/O error) or does
|
||||
/// not parse is moved aside for recovery, and the defaults are used; it
|
||||
/// is never silently overwritten with them. It goes to the first free
|
||||
/// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so
|
||||
/// a second unreadable file never replaces the copy kept from the first.
|
||||
/// If every name is taken or the move fails, the answer is false: the
|
||||
/// original is still at `path`, and the save at exit must not replace it.
|
||||
pub fn load_from(path: &Path) -> (Config, bool) {
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(mut config) = text.and_then(|t| serde_json::from_str::<Config>(&t).ok()) {
|
||||
config.sanitize();
|
||||
return (config, true);
|
||||
}
|
||||
let Some(aside) = free_aside_name(path) else {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run",
|
||||
path.display(),
|
||||
path.with_extension("json.corrupt").display(),
|
||||
ASIDE_SLOTS - 1
|
||||
);
|
||||
return (Config::default(), false);
|
||||
};
|
||||
match fs::rename(path, &aside) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read; moved it to {} and started with defaults",
|
||||
path.display(),
|
||||
aside.display()
|
||||
);
|
||||
(Config::default(), true)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run",
|
||||
path.display()
|
||||
);
|
||||
(Config::default(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves half a file.
|
||||
pub fn save_to(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(&tmp, text)?;
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
fn sanitize(&mut self) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
|
||||
self.servers.truncate(MAX_SERVERS);
|
||||
self.window = self.window.and_then(WindowFrame::sanitized);
|
||||
}
|
||||
|
||||
/// Move (or add) a server to the top of the recent list. Returns the
|
||||
/// Keychain accounts, as (url, username), of the entries with a username
|
||||
/// that fall off the end of the list (it keeps `MAX_SERVERS`), for the
|
||||
/// caller to delete. A different username on `url` itself is not
|
||||
/// reported: the caller deletes that account before it saves the new
|
||||
/// one, comparing the names exactly as the Keychain account string
|
||||
/// does. A delete made after the save could take the new password with
|
||||
/// it on a Keychain that treats two spellings of a name as one item.
|
||||
#[must_use = "the returned Keychain accounts must be deleted"]
|
||||
pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> {
|
||||
self.servers.retain(|s| s.url != url);
|
||||
self.servers.insert(
|
||||
0,
|
||||
ServerEntry {
|
||||
url: url.to_string(),
|
||||
username: username.map(str::to_string),
|
||||
has_saved_password: saved,
|
||||
},
|
||||
);
|
||||
let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS));
|
||||
evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect()
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, url: &str) -> Option<ServerEntry> {
|
||||
let idx = self.servers.iter().position(|s| s.url == url)?;
|
||||
Some(self.servers.remove(idx))
|
||||
}
|
||||
|
||||
pub fn find(&self, url: &str) -> Option<&ServerEntry> {
|
||||
self.servers.iter().find(|s| s.url == url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame {
|
||||
WindowFrame {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
|
||||
const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT);
|
||||
|
||||
fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool {
|
||||
f.title_screen(screens).is_some()
|
||||
}
|
||||
|
||||
/// The outer frame (title bar included) as left, top, right, bottom.
|
||||
fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) {
|
||||
let top = f.y - TITLE_ABOVE_Y;
|
||||
(f.x, top, f.x + f.width, top + TITLE_BAR + f.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_default() {
|
||||
let dir = tmp_dir("missing");
|
||||
let (c, can_save) = Config::load_from(&dir.join("desktop.json"));
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.auto_connect);
|
||||
assert!(can_save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_atomic_save() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
let path = dir.join("nested").join("desktop.json");
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty());
|
||||
assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty());
|
||||
c.start_fullscreen = true;
|
||||
c.window = Some(WindowFrame {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 1200.0,
|
||||
height: 800.0,
|
||||
fullscreen: true,
|
||||
});
|
||||
c.save_to(&path).unwrap();
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let (back, can_save) = Config::load_from(&path);
|
||||
assert!(can_save);
|
||||
assert_eq!(back, c);
|
||||
assert_eq!(back.servers[0].url, "https://box.ts.net/");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!text.to_lowercase().contains("password\":\""),
|
||||
"no password values on disk"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_moved_aside() {
|
||||
let dir = tmp_dir("corrupt");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_file_is_moved_aside() {
|
||||
let dir = tmp_dir("non-utf8");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
// Valid JSON apart from a Latin-1 "ü", which is not UTF-8.
|
||||
let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}";
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_path_is_moved_aside() {
|
||||
let dir = tmp_dir("unreadable");
|
||||
let path = dir.join("desktop.json");
|
||||
// A directory where the file should be: it exists but cannot be read.
|
||||
fs::create_dir_all(path.join("inside")).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").join("inside").is_dir());
|
||||
c.save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_corruption_keeps_the_first_copy() {
|
||||
let dir = tmp_dir("corrupt-twice");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ first").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
fs::write(&path, "{ second").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(),
|
||||
"{ second"
|
||||
);
|
||||
// A good file written afterwards loads normally and moves nothing.
|
||||
Config::default().save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!dir.join("desktop.json.corrupt.2").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taken_aside_names_are_skipped() {
|
||||
let dir = tmp_dir("aside-taken");
|
||||
// A directory left at the first name and a file at the second: the
|
||||
// third is the first free one.
|
||||
fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap();
|
||||
fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap();
|
||||
let next = if cfg!(unix) {
|
||||
"desktop.json.corrupt.3"
|
||||
} else {
|
||||
"desktop.json.corrupt.2"
|
||||
};
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").is_dir());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept");
|
||||
// The dangling symlink still points nowhere and still counts as taken.
|
||||
#[cfg(unix)]
|
||||
assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2"))
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_free_aside_name_disables_saving() {
|
||||
let dir = tmp_dir("aside-full");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
let names: Vec<PathBuf> = std::iter::once(dir.join("desktop.json.corrupt"))
|
||||
.chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}"))))
|
||||
.collect();
|
||||
assert_eq!(names.len(), 10);
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
fs::write(name, format!("copy {n}")).unwrap();
|
||||
}
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), false));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}"));
|
||||
}
|
||||
assert!(!dir.join("desktop.json.corrupt.10").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_move_aside_disables_saving() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tmp_dir("stuck");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores the read-only directory, so the rename cannot fail.
|
||||
if fs::write(dir.join("probe"), "").is_ok() {
|
||||
eprintln!("skipped: the directory is still writable (running as root?)");
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!can_save);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
assert!(!dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_deduped_and_capped() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..12 {
|
||||
assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h5/", Some("me"), false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert_eq!(c.servers[0].url, "http://h5/");
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1);
|
||||
assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me"));
|
||||
assert!(c.find("http://h5/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_leaves_a_username_change_to_the_caller() {
|
||||
let url = "https://box.ts.net/";
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember(url, Some("Alice"), true).is_empty());
|
||||
// Every change of the username on the same server, a case-only one,
|
||||
// another user, or none at all, returns nothing: the caller deletes
|
||||
// the old account before it saves the new one.
|
||||
for (user, saved) in [
|
||||
(Some("alice"), true),
|
||||
(Some(" alice "), true),
|
||||
(Some("bob"), true),
|
||||
(None, false),
|
||||
(None, false),
|
||||
(Some("dave"), true),
|
||||
] {
|
||||
assert!(c.remember(url, user, saved).is_empty(), "{user:?}");
|
||||
let entry = c.find(url).unwrap();
|
||||
assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved));
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1);
|
||||
}
|
||||
assert!(c.remember("http://other/", Some("carol"), true).is_empty());
|
||||
assert!(c.remember(url, Some("erin"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), 2);
|
||||
assert_eq!(c.servers[0].url, url);
|
||||
assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_returns_evicted_accounts() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
let user = (i % 2 == 0).then(|| format!("u{i}"));
|
||||
assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty());
|
||||
}
|
||||
// Moving a listed server to the top evicts nothing.
|
||||
assert!(c.remember("http://h3/", None, false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
// A new server pushes the oldest (h0, with a username) off the end.
|
||||
assert_eq!(
|
||||
c.remember("http://new/", None, false),
|
||||
vec![("http://h0/".to_string(), "u0".to_string())]
|
||||
);
|
||||
// The next oldest is h1, which has no username: nothing to delete.
|
||||
assert!(c.remember("http://newer/", Some("x"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none());
|
||||
// A username change on a listed server returns nothing; the next new
|
||||
// server evicts the oldest entry, and only that account comes back.
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h7/", Some("v"), true).is_empty());
|
||||
assert_eq!(
|
||||
c.remember("http://h8/", Some("w"), true),
|
||||
vec![("http://h0/".to_string(), "u".to_string())]
|
||||
);
|
||||
assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v"));
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_missing_fields_tolerated() {
|
||||
let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap();
|
||||
assert!(c.auto_connect);
|
||||
assert_eq!(c.servers[0].username, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sanitizing_and_reachability() {
|
||||
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
|
||||
let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap();
|
||||
assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT));
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let on = frame(100.0, 50.0, 1200.0, 800.0);
|
||||
assert!(reachable(&on, &screen));
|
||||
assert_eq!(on.fitted_to(&screen, MIN), Some(on));
|
||||
let off = frame(3000.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&off, &screen));
|
||||
assert_eq!(off.fitted_to(&screen, MIN), None);
|
||||
let above = frame(100.0, -900.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&above, &screen));
|
||||
assert_eq!(above.fitted_to(&screen, MIN), None);
|
||||
// Only a sliver of the title bar on screen is not enough to grab.
|
||||
let sliver = frame(-1150.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&sliver, &screen));
|
||||
assert_eq!(sliver.fitted_to(&screen, MIN), None);
|
||||
// No screens at all (none reported): nothing is reachable.
|
||||
assert_eq!(on.fitted_to(&[], MIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_is_above_y_on_macos() {
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
// On macOS the title bar of a content top at y = 10 is at -18..10,
|
||||
// mostly above the screen; elsewhere it is at 10..38.
|
||||
let high = frame(100.0, 10.0, 1200.0, 800.0);
|
||||
assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos"));
|
||||
// Half the bar above the top edge: still grabbable, and fitting
|
||||
// brings all of it onto the screen.
|
||||
let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0);
|
||||
assert!(reachable(&straddling, &screen));
|
||||
let fitted = straddling.fitted_to(&screen, MIN).unwrap();
|
||||
assert_eq!(outer(&fitted).1, 0.0);
|
||||
assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_from_an_unplugged_ultrawide_is_fitted() {
|
||||
// Saved on a 3440x1440 display at the origin, restored on a laptop.
|
||||
let laptop = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let wide = frame(0.0, 30.0, 3300.0, 1300.0);
|
||||
let fitted = wide.fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y)
|
||||
);
|
||||
let (left, top, right, bottom) = outer(&fitted);
|
||||
assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0);
|
||||
// Hanging off the right of the laptop by a bit: shifted, not shrunk.
|
||||
let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(312.0, 100.0, 1200.0, 700.0)
|
||||
);
|
||||
// Hanging off the left: shifted right.
|
||||
let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!((fitted.x, fitted.width), (0.0, 1200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_spanning_two_displays_is_left_alone() {
|
||||
let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)];
|
||||
let span = frame(1000.0, 100.0, 1500.0, 700.0);
|
||||
assert_eq!(span.fitted_to(&screens, MIN), Some(span));
|
||||
// Only the edge that lands on no display moves: the left edge stays
|
||||
// on the laptop, the right edge comes back onto the external one.
|
||||
let past = frame(1000.0, 100.0, 3500.0, 700.0);
|
||||
let fitted = past.fitted_to(&screens, MIN).unwrap();
|
||||
assert_eq!(fitted.x, 1000.0);
|
||||
assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X);
|
||||
assert_eq!((fitted.y, fitted.height), (100.0, 700.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_frame_keeps_the_minimum_size() {
|
||||
let tiny = [rect(0.0, 0.0, 400.0, 300.0)];
|
||||
let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap();
|
||||
assert_eq!((fitted.width, fitted.height), MIN);
|
||||
// Too big for the screen even at the minimum: the left edge and the
|
||||
// title bar stay on it.
|
||||
let (left, top, _, _) = outer(&fitted);
|
||||
assert_eq!((left, top), (0.0, 0.0));
|
||||
assert!(reachable(&fitted, &tiny));
|
||||
// The fullscreen flag rides along.
|
||||
let full = WindowFrame {
|
||||
fullscreen: true,
|
||||
..frame(0.0, 40.0, 1200.0, 800.0)
|
||||
};
|
||||
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item
|
||||
//! per username + server). Nothing secret is ever written to the config file.
|
||||
//! Windows and Linux get their stores when those builds land.
|
||||
//!
|
||||
//! Every Keychain call runs on ONE long-lived worker thread, never on the
|
||||
//! event loop: any of them can bring up the system's access prompt (an
|
||||
//! ad-hoc build's items name the previous build), and the window must keep
|
||||
//! running while it is up. One serial worker rather than a thread per call,
|
||||
//! because jobs must run in the order they were asked for: a delete from
|
||||
//! Forget that ran after a later save of the same account would remove the
|
||||
//! password that was just saved.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
pub const SERVICE: &str = "Codeman Desktop";
|
||||
|
||||
/// What a job reports when the worker thread is gone.
|
||||
const WORKER_GONE: &str = "The Keychain could not be reached.";
|
||||
|
||||
/// The Keychain account name for a username on a server.
|
||||
pub fn account(server_url: &str, username: &str) -> String {
|
||||
format!("{username} @ {server_url}")
|
||||
}
|
||||
|
||||
/// The password store the worker runs jobs against: the Keychain on macOS,
|
||||
/// a stub elsewhere, an in-memory map in the tests.
|
||||
trait Backend: Send {
|
||||
fn load(&mut self, account: &str) -> Option<String>;
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String>;
|
||||
fn delete(&mut self, account: &str);
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Load {
|
||||
account: String,
|
||||
reply: Sender<Option<String>>,
|
||||
},
|
||||
Save {
|
||||
account: String,
|
||||
password: String,
|
||||
reply: Sender<Result<(), String>>,
|
||||
},
|
||||
Delete {
|
||||
account: String,
|
||||
},
|
||||
/// Ends the worker, so the tests can check what callers see after it.
|
||||
#[cfg(test)]
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`]
|
||||
/// and clone the handle: every clone feeds the same queue, which is what
|
||||
/// keeps jobs in order. The worker ends when the last handle is dropped.
|
||||
#[derive(Clone)]
|
||||
pub struct Keychain {
|
||||
jobs: Sender<Job>,
|
||||
}
|
||||
|
||||
impl Keychain {
|
||||
/// Starts the worker. If the thread cannot be started, the handle still
|
||||
/// works and every call fails soft (no password, save refused).
|
||||
pub fn spawn() -> Keychain {
|
||||
start(Box::new(imp::System)).0
|
||||
}
|
||||
|
||||
/// The saved password for an account. Blocks until every job queued
|
||||
/// before it has run and the Keychain has answered, which can mean
|
||||
/// waiting on the access prompt, so call it from a worker thread.
|
||||
/// `None` when nothing is saved, the item is unreadable or the worker
|
||||
/// is gone.
|
||||
pub fn load_blocking(&self, account: &str) -> Option<String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Load {
|
||||
account: account.to_owned(),
|
||||
reply,
|
||||
};
|
||||
self.jobs.send(job).ok()?;
|
||||
answer.recv().ok().flatten()
|
||||
}
|
||||
|
||||
/// Saves a password, replacing any saved before. Blocks like
|
||||
/// [`Keychain::load_blocking`]; the result is the Keychain's own.
|
||||
pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Save {
|
||||
account: account.to_owned(),
|
||||
password: password.to_owned(),
|
||||
reply,
|
||||
};
|
||||
if self.jobs.send(job).is_err() {
|
||||
return Err(WORKER_GONE.into());
|
||||
}
|
||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||
}
|
||||
|
||||
/// Queues the removal of an account's password and returns at once.
|
||||
/// Jobs queued after it see the account gone. Nothing happens when the
|
||||
/// worker is gone.
|
||||
pub fn delete(&self, account: &str) {
|
||||
let _ = self.jobs.send(Job::Delete {
|
||||
account: account.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a worker on `backend`. The join handle is for the tests; `None`
|
||||
/// when the thread could not be started, in which case the receiver went
|
||||
/// down with the closure and every send fails.
|
||||
fn start(backend: Box<dyn Backend>) -> (Keychain, Option<JoinHandle<()>>) {
|
||||
let (jobs, queue) = mpsc::channel();
|
||||
let worker = thread::Builder::new()
|
||||
.name("keychain".into())
|
||||
.spawn(move || run(backend, queue))
|
||||
.ok();
|
||||
(Keychain { jobs }, worker)
|
||||
}
|
||||
|
||||
/// The worker loop: one job at a time, in the order they were sent, until
|
||||
/// every handle is gone. A reply nobody waits for any more is dropped.
|
||||
fn run(mut backend: Box<dyn Backend>, queue: Receiver<Job>) {
|
||||
for job in queue {
|
||||
match job {
|
||||
Job::Load { account, reply } => {
|
||||
let _ = reply.send(backend.load(&account));
|
||||
}
|
||||
Job::Save {
|
||||
account,
|
||||
password,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(backend.save(&account, &password));
|
||||
}
|
||||
Job::Delete { account } => backend.delete(&account),
|
||||
#[cfg(test)]
|
||||
Job::Stop => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::{Backend, SERVICE};
|
||||
use objc2::rc::autoreleasepool;
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
/// The login Keychain, through security-framework. The worker lives as
|
||||
/// long as the app and a thread's implicit autorelease pool drains only
|
||||
/// when the thread ends, so each call gets its own pool and whatever
|
||||
/// Security autoreleases is freed with the job.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
autoreleasepool(|_| {
|
||||
get_generic_password(SERVICE, account)
|
||||
.ok()
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&mut self, account: &str) {
|
||||
autoreleasepool(|_| {
|
||||
let _ = delete_generic_password(SERVICE, account);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod imp {
|
||||
use super::Backend;
|
||||
|
||||
/// No password store yet on this platform.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Err("Saving passwords is not supported on this platform yet.".into())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// An in-memory store that logs every job it runs. Its delete is slow on
|
||||
/// purpose: a queue that did not keep order would let the next job
|
||||
/// overtake it, and the tests would see that.
|
||||
#[derive(Clone, Default)]
|
||||
struct Memory {
|
||||
items: Arc<Mutex<HashMap<String, String>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn note(&self, entry: String) {
|
||||
self.log.lock().unwrap().push(entry);
|
||||
}
|
||||
fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Memory {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
self.note(format!("load {account}"));
|
||||
self.items.lock().unwrap().get(account).cloned()
|
||||
}
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
self.note(format!("save {account}"));
|
||||
self.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(account.to_owned(), password.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, account: &str) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
self.note(format!("delete {account}"));
|
||||
self.items.lock().unwrap().remove(account);
|
||||
}
|
||||
}
|
||||
|
||||
/// A store whose load fails the way a crashed worker would.
|
||||
struct Crashes;
|
||||
|
||||
impl Backend for Crashes {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
panic!("keychain worker crashed");
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
|
||||
fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_names_are_per_user_and_server() {
|
||||
assert_eq!(
|
||||
super::account("http://127.0.0.1:3000/", "admin"),
|
||||
"admin @ http://127.0.0.1:3000/"
|
||||
);
|
||||
assert_ne!(
|
||||
super::account("http://a/", "admin"),
|
||||
super::account("http://b/", "admin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_reads_what_a_save_wrote() {
|
||||
let (keychain, _) = keychain();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret"));
|
||||
assert_eq!(keychain.save_blocking("a", "changed"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed"));
|
||||
assert_eq!(keychain.load_blocking("b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_before_a_save_never_removes_the_saved_password() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "old"), Ok(()));
|
||||
keychain.delete("a");
|
||||
assert_eq!(keychain.save_blocking("a", "new"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("new"));
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_after_a_save_removes_it_before_the_next_job() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.delete("a");
|
||||
// No wait between the two: the load is queued behind the delete.
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_from_clones_on_other_threads_share_one_queue() {
|
||||
let (keychain, memory) = keychain();
|
||||
let other = keychain.clone();
|
||||
std::thread::spawn(move || {
|
||||
other.save_blocking("a", "secret").unwrap();
|
||||
other.delete("a");
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_call_fails_soft_once_the_worker_is_gone() {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.jobs.send(Job::Stop).unwrap();
|
||||
worker.unwrap().join().unwrap();
|
||||
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
keychain.clone().delete("a");
|
||||
assert_eq!(memory.log(), ["save a"]);
|
||||
}
|
||||
|
||||
#[cfg(panic = "unwind")]
|
||||
#[test]
|
||||
fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() {
|
||||
let (keychain, worker) = start(Box::new(Crashes));
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(worker.unwrap().join().is_err(), "the worker panicked");
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_is_send_and_sync() {
|
||||
fn send_sync_clone<T: Send + Sync + Clone + 'static>() {}
|
||||
send_sync_clone::<Keychain>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||
//!
|
||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||
//! environment it is started with: every session it spawns looks for
|
||||
//! claude/codex/tmux on that PATH and reads provider keys from it. So the
|
||||
//! command runs with the login shell's environment, picking up Homebrew and the
|
||||
//! user's profile, and falls back to the interactive profile (`-i`) for setups
|
||||
//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path
|
||||
//! are passed as separate arguments (`"$@"`), never spliced into the script text.
|
||||
//!
|
||||
//! How depends on the shell, chosen by its basename as Codeman's own
|
||||
//! `src/utils/shell-resolver.ts` does:
|
||||
//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself:
|
||||
//! `$SHELL -l [-i] -c SCRIPT codeman-desktop <args>`.
|
||||
//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse
|
||||
//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to
|
||||
//! print its environment between two markers, and `SCRIPT` then runs under
|
||||
//! `/bin/sh` with exactly that environment.
|
||||
//!
|
||||
//! `codeman web -d` refuses to start a second server on the same data dir and
|
||||
//! returns once the new one answers `/api/status`, so this is safe to call
|
||||
//! only after the probe found nothing listening.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// For the whole start, every shell run and retry included.
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long the pipes may stay open after the shell exited. Something the
|
||||
/// profile started in the background can inherit them and keep them open for
|
||||
/// as long as it runs; whatever arrived by then is used.
|
||||
const PIPE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`.
|
||||
const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then
|
||||
exec node "$HOME/.codeman/app/dist/index.js" "$@"
|
||||
fi
|
||||
echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2
|
||||
exit 127"#;
|
||||
|
||||
/// Bracket the environment a non-POSIX login shell prints, so whatever its
|
||||
/// profile writes to stdout before or after cannot be read as a variable.
|
||||
const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__";
|
||||
const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__";
|
||||
|
||||
/// What a non-POSIX login shell runs to report its environment. The shell
|
||||
/// itself only parses a command path and one single-quoted word with no `;`,
|
||||
/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have
|
||||
/// no syntax of their own to disagree on. The markers and `env` run under
|
||||
/// `/bin/sh`, which inherits everything the profile exported.
|
||||
fn capture_command() -> String {
|
||||
format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'")
|
||||
}
|
||||
|
||||
fn login_shell() -> PathBuf {
|
||||
std::env::var_os("SHELL")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("/bin/zsh"))
|
||||
}
|
||||
|
||||
/// How the login shell takes part in a start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Flavor {
|
||||
/// Runs `SCRIPT` itself.
|
||||
Posix,
|
||||
/// Only reports its environment. One entry per attempt, in order: a later
|
||||
/// one runs when the earlier one printed no environment or its environment
|
||||
/// had no `codeman` (exit 127).
|
||||
Capture(&'static [&'static [&'static str]]),
|
||||
}
|
||||
|
||||
fn flavor(shell: &Path) -> Flavor {
|
||||
match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix,
|
||||
// tcsh honors -l only when it is the ONLY flag, so a login shell that
|
||||
// also runs a command is impossible; -i at least reads all of .tcshrc
|
||||
// (csh: .cshrc), including the parts guarded by `$?prompt`.
|
||||
"csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]),
|
||||
_ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// What one process printed, stdout and stderr kept apart.
|
||||
struct Output {
|
||||
code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
|
||||
struct Run {
|
||||
code: Option<i32>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl From<Output> for Run {
|
||||
fn from(out: Output) -> Self {
|
||||
let mut output = out.stdout;
|
||||
if !out.stderr.trim().is_empty() {
|
||||
output.push_str(&out.stderr);
|
||||
}
|
||||
Run {
|
||||
code: out.code,
|
||||
output: last_lines(&output, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send
|
||||
/// `None` once it closes.
|
||||
fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Sender<(usize, Option<Vec<u8>>)>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut pipe) = pipe {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send((stream, Some(buf[..n].to_vec()))).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send((stream, None));
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `cmd` to completion with stdin closed, or kill it at `deadline`.
|
||||
fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?;
|
||||
|
||||
// Read both pipes on threads so a chatty profile cannot fill one and stall.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pump(child.stdout.take(), 0, tx.clone());
|
||||
pump(child.stderr.take(), 1, tx);
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("Starting Codeman took longer than a minute; it was stopped.".into());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let mut bufs = [Vec::new(), Vec::new()];
|
||||
let settle = (Instant::now() + PIPE_GRACE).min(deadline);
|
||||
let mut open = 2;
|
||||
while open > 0 {
|
||||
match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) {
|
||||
Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes),
|
||||
Ok((_, None)) => open -= 1,
|
||||
Err(_) => {
|
||||
// Still held open past the grace: keep what already arrived.
|
||||
for (stream, bytes) in rx.try_iter() {
|
||||
if let Some(bytes) = bytes {
|
||||
bufs[stream].extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lossy rather than strict: one stray non-UTF-8 byte in a profile's output
|
||||
// must not cost the whole environment.
|
||||
let [out, err] = bufs;
|
||||
Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&out).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&err).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The tail of what the command printed, for the connect page.
|
||||
fn last_lines(text: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// The variables `env` printed between the markers, or None when the output
|
||||
/// does not hold both. A line that does not start with a portable variable
|
||||
/// name followed by `=` continues the previous value (a value with a newline
|
||||
/// in it); one that does starts the next variable.
|
||||
fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
||||
let mut lines = output.split('\n');
|
||||
// ends_with: a profile line printed without its newline sits in front.
|
||||
lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?;
|
||||
let mut vars: Vec<(String, String)> = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim() == ENV_END {
|
||||
return Some(vars);
|
||||
}
|
||||
match env_entry(line) {
|
||||
Some((name, value)) => vars.push((name.to_string(), value.to_string())),
|
||||
None => {
|
||||
if let Some((_, value)) = vars.last_mut() {
|
||||
value.push('\n');
|
||||
value.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
let portable =
|
||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
portable.then_some((name, value))
|
||||
}
|
||||
|
||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||
/// start (the shell could not be run, or time is up); the inner one means this
|
||||
/// form printed no environment and the next may be tried.
|
||||
fn capture_env(
|
||||
shell: &Path,
|
||||
flags: &[&str],
|
||||
deadline: Instant,
|
||||
) -> Result<Result<Vec<(String, String)>, String>, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(flags).arg(capture_command());
|
||||
let out = exec(cmd, deadline)?;
|
||||
Ok(parse_env(&out.stdout).ok_or_else(|| {
|
||||
// stderr only: stdout can hold part of the environment, API keys included.
|
||||
let said = last_lines(&out.stderr, 6);
|
||||
let mut message = format!("Could not read the environment of your login shell {}", shell.display());
|
||||
if said.is_empty() {
|
||||
let code = out
|
||||
.code
|
||||
.map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}"));
|
||||
message.push_str(&format!(" (it ended with {code})."));
|
||||
} else {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&said);
|
||||
}
|
||||
message
|
||||
}))
|
||||
}
|
||||
|
||||
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
|
||||
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
|
||||
/// settings and nothing of launchd's bare one.
|
||||
fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result<Run, String> {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// `script` run by the POSIX login shell itself.
|
||||
fn run_in_shell(
|
||||
script: &str,
|
||||
shell: &Path,
|
||||
interactive: bool,
|
||||
args: &[String],
|
||||
deadline: Instant,
|
||||
) -> Result<Run, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg("-l");
|
||||
if interactive {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// Run `script` the way the login shell allows (see the module docs). Exit
|
||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||
/// interactive profile is asked only when `interactive` allows it.
|
||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||
let attempts = match flavor(shell) {
|
||||
Flavor::Posix => {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
}
|
||||
Flavor::Capture(attempts) => attempts,
|
||||
};
|
||||
let mut last_run = None;
|
||||
let mut capture_error = None;
|
||||
let interactive_flag = |flags: &&&[&str]| {
|
||||
flags
|
||||
.iter()
|
||||
.any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i'))
|
||||
};
|
||||
for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) {
|
||||
match capture_env(shell, flags, deadline)? {
|
||||
Ok(env) => {
|
||||
let result = run_with_env(script, &env, args, deadline)?;
|
||||
if result.code != Some(127) {
|
||||
return Ok(result);
|
||||
}
|
||||
last_run = Some(result);
|
||||
}
|
||||
Err(e) => capture_error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_run {
|
||||
return Ok(result);
|
||||
}
|
||||
// No form printed an environment: a shell that knows neither -l nor -i
|
||||
// may still run a bare -c.
|
||||
match capture_env(shell, &["-c"], deadline)? {
|
||||
Ok(env) => run_with_env(script, &env, args, deadline),
|
||||
Err(e) => Err(capture_error.unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the
|
||||
/// `install.sh` clone), 127 when it would not. Same lookup, nothing started.
|
||||
const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||
exit 127"#;
|
||||
|
||||
/// How long the This Mac tab waits for the login shell to answer whether
|
||||
/// Codeman is installed.
|
||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn installed_with(shell: &Path, interactive: bool) -> bool {
|
||||
matches!(
|
||||
launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive),
|
||||
Ok(Run { code: Some(0), .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman`
|
||||
/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs
|
||||
/// the login shell, so never on the event loop. `interactive` also asks the
|
||||
/// interactive profile (nvm in `.zshrc`, say), whose side effects only a
|
||||
/// user's own click should set off.
|
||||
pub fn installed(interactive: bool) -> bool {
|
||||
installed_with(&login_shell(), interactive)
|
||||
}
|
||||
|
||||
/// `start` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn start_with(shell: &Path, args: &[String]) -> Result<String, String> {
|
||||
let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?;
|
||||
match result.code {
|
||||
Some(0) => Ok(result.output),
|
||||
_ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()),
|
||||
_ => Err(result.output),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a detached server for `url`. Ok carries what `codeman` printed.
|
||||
pub fn start(url: &Url) -> Result<String, String> {
|
||||
if !policy::can_start_locally(url) {
|
||||
return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into());
|
||||
}
|
||||
start_with(&login_shell(), &policy::start_args(url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One spawning test at a time. A test writes a fake executable and then
|
||||
/// runs it; a process forked by a parallel test in between can still hold
|
||||
/// the write descriptor, and the exec then fails with ETXTBSY.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_of_output() {
|
||||
assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc");
|
||||
assert_eq!(last_lines("", 3), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_non_local_or_https() {
|
||||
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
|
||||
assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// The script passes its arguments through untouched and reports a missing
|
||||
/// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found).
|
||||
#[test]
|
||||
fn script_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "-d", "--port", "3000"])
|
||||
.env("PATH", "/nonexistent")
|
||||
.env("HOME", "/nonexistent")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(127));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command"));
|
||||
}
|
||||
|
||||
/// Arguments are data: a hostile-looking value is passed as one argv entry
|
||||
/// to a fake `codeman`, never run by the shell.
|
||||
#[test]
|
||||
fn arguments_are_not_shell_text() {
|
||||
let _serial = serial();
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fake = dir.join("codeman");
|
||||
std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "--base-url", "/x; touch /tmp/pwned"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
"[web][--base-url][/x; touch /tmp/pwned]"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_capture_parser() {
|
||||
let out = format!(
|
||||
"Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\
|
||||
PATH=/opt/homebrew/bin:/usr/bin:/bin\n\
|
||||
MULTI=line one\nline two\n three = 3\n\
|
||||
EMPTY=\n\
|
||||
TRAIL=a \n\n\
|
||||
_=/usr/bin/env\n\
|
||||
{ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env(&out),
|
||||
Some(pairs(&[
|
||||
("PATH", "/opt/homebrew/bin:/usr/bin:/bin"),
|
||||
("MULTI", "line one\nline two\n three = 3"),
|
||||
("EMPTY", ""),
|
||||
("TRAIL", "a \n"),
|
||||
("_", "/usr/bin/env"),
|
||||
]))
|
||||
);
|
||||
// A value may itself contain `=`; only the first one splits.
|
||||
assert_eq!(
|
||||
parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")),
|
||||
Some(pairs(&[("OPTS", "a=b")]))
|
||||
);
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![]));
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None);
|
||||
assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None);
|
||||
assert_eq!(parse_env("fish: Unknown command: then\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_flavor_by_basename() {
|
||||
for posix in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/dash",
|
||||
"/bin/ksh",
|
||||
"/opt/homebrew/bin/bash",
|
||||
] {
|
||||
assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}");
|
||||
}
|
||||
assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]]));
|
||||
assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]]));
|
||||
for other in [
|
||||
"/opt/homebrew/bin/fish",
|
||||
"/usr/local/bin/nu",
|
||||
"/opt/homebrew/bin/xonsh",
|
||||
"/usr/local/bin/pwsh",
|
||||
] {
|
||||
assert_eq!(
|
||||
flavor(Path::new(other)),
|
||||
Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The login shell's part avoids everything only POSIX shells read.
|
||||
#[test]
|
||||
fn capture_command_is_shell_neutral() {
|
||||
let _serial = serial();
|
||||
let cmd = capture_command();
|
||||
for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] {
|
||||
assert!(!cmd.contains(bad), "{bad} in {cmd}");
|
||||
}
|
||||
assert_eq!(cmd.matches('\'').count(), 2);
|
||||
assert!(cmd.starts_with("/bin/sh -c '"));
|
||||
// /bin/sh, as the capture runs it, prints a parsable environment.
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines")
|
||||
.output()
|
||||
.unwrap();
|
||||
let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers");
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines"));
|
||||
assert!(env.iter().any(|(k, _)| k == "PATH"));
|
||||
}
|
||||
|
||||
/// A scratch directory with a fake `codeman` in `bin/` that prints its
|
||||
/// arguments, the profile's marker variable and the names of everything in
|
||||
/// its environment.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("home")).unwrap();
|
||||
let scratch = Scratch(dir);
|
||||
scratch.exe(
|
||||
"bin/codeman",
|
||||
"#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\
|
||||
printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n",
|
||||
);
|
||||
scratch
|
||||
}
|
||||
|
||||
/// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path.
|
||||
fn exe(&self, rel: &str, body: &str) -> PathBuf {
|
||||
let path = self.0.join(rel);
|
||||
std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn argv_log(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"];
|
||||
|
||||
fn hostile_args() -> Vec<String> {
|
||||
HOSTILE_ARGS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish
|
||||
/// does, logs its flags, prints profile noise around whatever it runs, and
|
||||
/// its "profile" replaces the environment. On its interactive form only
|
||||
/// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`.
|
||||
const FAKE_FISH: &str = r#"#!/bin/sh
|
||||
args=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
*then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;;
|
||||
-*) args="$args$a ";;
|
||||
esac
|
||||
cmd="$a"
|
||||
done
|
||||
echo "$args" >> "DIR/argv.log"
|
||||
bin="DIR/bin"
|
||||
case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac
|
||||
echo "Welcome to fish, the friendly interactive shell"
|
||||
/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd"
|
||||
echo "bye from the profile"
|
||||
"#;
|
||||
|
||||
/// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the
|
||||
/// environment the shell printed, and with nothing else.
|
||||
#[test]
|
||||
fn non_posix_shell_only_reports_its_environment() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]");
|
||||
assert_eq!(lines[1], "profile: from the profile");
|
||||
assert_eq!(s.argv_log(), "-l -c \n");
|
||||
|
||||
// env_clear: a variable this process has, but the profile did not
|
||||
// export, must not reach codeman.
|
||||
let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect();
|
||||
assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST"));
|
||||
let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
let leaked: Vec<String> = std::env::vars_os()
|
||||
.filter_map(|(k, _)| k.into_string().ok())
|
||||
.filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF"))
|
||||
.filter(|k| names.contains(&k.as_str()))
|
||||
.collect();
|
||||
assert!(leaked.is_empty(), "leaked from the app: {leaked:?}");
|
||||
}
|
||||
|
||||
/// No codeman on the login PATH (exit 127): the environment is captured
|
||||
/// again from the interactive profile, and that run finds it.
|
||||
#[test]
|
||||
fn non_posix_shell_retries_with_the_interactive_profile() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish-retry");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n");
|
||||
}
|
||||
|
||||
/// tcsh takes -l only as its only flag, so it is asked with -ic alone.
|
||||
#[test]
|
||||
fn tcsh_gets_ic_and_never_l() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("tcsh");
|
||||
let tcsh = s.exe(
|
||||
"tcsh",
|
||||
r#"#!/bin/sh
|
||||
echo "$*" >> "DIR/argv.log"
|
||||
if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&tcsh, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: tcsh"), "{out}");
|
||||
assert_eq!(s.argv_log().lines().count(), 1);
|
||||
assert!(s.argv_log().starts_with("-ic /bin/sh -c '"));
|
||||
}
|
||||
|
||||
/// A shell that takes neither -l nor -i still gets a bare -c.
|
||||
#[test]
|
||||
fn shell_without_login_flags_falls_back_to_plain_c() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("nolflag");
|
||||
let shell = s.exe(
|
||||
"elvish",
|
||||
r#"#!/bin/sh
|
||||
echo "$1" >> "DIR/argv.log"
|
||||
case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&shell, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: plain"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l\n-l\n-c\n");
|
||||
}
|
||||
|
||||
/// A shell that never prints its environment is reported by name with what
|
||||
/// it said on stderr, never with stdout (which can carry secrets).
|
||||
#[test]
|
||||
fn capture_failure_names_the_shell() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("broken");
|
||||
let shell = s.exe(
|
||||
"weirdsh",
|
||||
"#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n",
|
||||
);
|
||||
let err = start_with(&shell, &hostile_args()).unwrap_err();
|
||||
assert!(err.contains("Could not read the environment"), "{err}");
|
||||
assert!(err.contains("weirdsh: cannot start"), "{err}");
|
||||
assert!(!err.contains("sk-secret"), "{err}");
|
||||
}
|
||||
|
||||
/// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop
|
||||
/// args`, then `-l -i -c` when the login PATH had no codeman.
|
||||
#[test]
|
||||
fn posix_shell_runs_script_itself_and_retries_interactive() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&zsh, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
// $0 of the script, once per run: login, then interactive.
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n");
|
||||
}
|
||||
|
||||
/// The installed check asks the same shells the same way and runs nothing:
|
||||
/// found on the interactive PATH here, after the login PATH had none.
|
||||
#[test]
|
||||
fn installed_check_finds_codeman_without_running_it() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
// A codeman that would leave a mark if it ran.
|
||||
s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n");
|
||||
// Without the interactive profile: one login-shell look, not found.
|
||||
assert!(!installed_with(&zsh, false));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\n");
|
||||
assert!(installed_with(&zsh, true));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n");
|
||||
assert!(!s.0.join("ran").exists(), "the installed check ran codeman");
|
||||
}
|
||||
|
||||
/// Nothing on any PATH and no ~/.codeman/app: not installed. Through a
|
||||
/// non-POSIX shell too, which only reports its environment.
|
||||
#[test]
|
||||
fn installed_check_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-fish");
|
||||
std::fs::remove_file(s.0.join("bin/codeman")).unwrap();
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
assert!(!installed_with(&fish, true));
|
||||
// The login-only look asks fish once, with -l and never -i.
|
||||
let before = s.argv_log();
|
||||
assert!(!installed_with(&fish, false));
|
||||
assert_eq!(&s.argv_log()[before.len()..], "-l -c \n");
|
||||
|
||||
// The install.sh clone counts once node is there.
|
||||
let app = s.0.join("home/.codeman/app/dist");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
std::fs::write(app.join("index.js"), "").unwrap();
|
||||
s.exe("bin/node", "#!/bin/sh\nexit 0\n");
|
||||
assert!(installed_with(&fish, true));
|
||||
assert!(installed_with(&fish, false));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,727 @@
|
||||
//! "Is a Codeman server answering at this address, and does it want a login?"
|
||||
//!
|
||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||
//! The answer is classified into something the connect page can explain. It is
|
||||
//! asked once per user action, never in a loop: Codeman counts a request
|
||||
//! without valid credentials against the login rate limit (10 per IP).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
const STATUS_PATH: &str = "api/status";
|
||||
|
||||
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
|
||||
/// with a certificate the OS does not trust fails with. ureq hands it over as
|
||||
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
|
||||
/// without a direct rustls dependency.
|
||||
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
|
||||
|
||||
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
|
||||
/// cannot be matched on stable Rust, so the prefix is the only handle. The
|
||||
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
|
||||
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProbeOutcome {
|
||||
Ok {
|
||||
version: String,
|
||||
},
|
||||
/// 401 and we sent no credentials.
|
||||
NeedsLogin,
|
||||
/// 401 although we sent credentials.
|
||||
BadCredentials,
|
||||
/// 429: too many failed logins from this address. A single-user Codeman
|
||||
/// still accepts the right password, so the page needs to know whether
|
||||
/// this probe carried one. `retry_after_secs` is the server's
|
||||
/// `Retry-After`, when it gave one in seconds.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RateLimited {
|
||||
sent_credentials: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
retry_after_secs: Option<u64>,
|
||||
},
|
||||
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
|
||||
/// multi-user Codeman wants this account's password changed first. Page
|
||||
/// loads are exempt, so the web UI can show its change-password dialog.
|
||||
PasswordChangeRequired,
|
||||
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
|
||||
HostNotAllowed,
|
||||
/// 3xx. `location` is the target resolved against the probed URL.
|
||||
/// `suggested` is a server address to use instead, set only when the
|
||||
/// redirect stays on the same host, only moves to https or another port,
|
||||
/// and still points at the status API. Never followed: a second request
|
||||
/// would carry the credentials to an address the user did not type.
|
||||
Redirect {
|
||||
location: String,
|
||||
suggested: Option<String>,
|
||||
},
|
||||
/// Something answered, but not Codeman's status API.
|
||||
NotCodeman {
|
||||
status: u16,
|
||||
},
|
||||
Refused,
|
||||
/// No route to the address: the machine is off or on another network,
|
||||
/// or macOS's Local Network privacy setting blocked the app.
|
||||
Unreachable,
|
||||
/// An http:// probe whose connection was closed without an answer. A
|
||||
/// Codeman started with `--https` does exactly that to plain HTTP.
|
||||
MaybeHttps,
|
||||
Timeout,
|
||||
TlsUntrusted {
|
||||
detail: String,
|
||||
},
|
||||
Unresolvable,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||
/// was asked, `location` and `retry_after` are those response headers.
|
||||
pub fn classify_response(
|
||||
request: &Url,
|
||||
status: u16,
|
||||
location: Option<&str>,
|
||||
retry_after: Option<&str>,
|
||||
sent_credentials: bool,
|
||||
body: &str,
|
||||
) -> ProbeOutcome {
|
||||
match status {
|
||||
200 => {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let version = parsed.as_ref().and_then(|v| {
|
||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
});
|
||||
match version {
|
||||
Some(version) => ProbeOutcome::Ok { version },
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
300..=399 => {
|
||||
// A 3xx without a usable Location (304, a garbled header) is not a
|
||||
// redirect anyone can act on.
|
||||
match location.and_then(|l| request.join(l.trim()).ok()) {
|
||||
Some(target) => ProbeOutcome::Redirect {
|
||||
suggested: suggested_server(request, &target),
|
||||
location: target.into(),
|
||||
},
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
401 if sent_credentials => ProbeOutcome::BadCredentials,
|
||||
401 => ProbeOutcome::NeedsLogin,
|
||||
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
|
||||
// Codeman only says this after the Basic credentials verified (the
|
||||
// probe sends no cookie), so without credentials it is not Codeman's
|
||||
// answer to us, and loading the page without them would not help.
|
||||
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
|
||||
ProbeOutcome::PasswordChangeRequired
|
||||
}
|
||||
429 => ProbeOutcome::RateLimited {
|
||||
sent_credentials,
|
||||
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
|
||||
},
|
||||
other => ProbeOutcome::NotCodeman { status: other },
|
||||
}
|
||||
}
|
||||
|
||||
/// `errorCode` of a Codeman `ApiResponse` error body.
|
||||
fn error_code(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
Some(parsed.get("errorCode")?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
/// The server base URL a redirect points to, when it is the same server
|
||||
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
|
||||
/// usual case). Anything else, such as another host (a login gateway the
|
||||
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
|
||||
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
|
||||
let upgrade = request.scheme() == "http" && target.scheme() == "https";
|
||||
let port_change =
|
||||
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
|
||||
if !(upgrade || port_change) || target.host() != request.host() {
|
||||
return None;
|
||||
}
|
||||
if !target.username().is_empty() || target.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
// The redirect must keep pointing at the status API (a proxy may add a
|
||||
// base path in front of it); the base is what comes before it.
|
||||
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
|
||||
return None;
|
||||
}
|
||||
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
|
||||
let mut base = target.clone();
|
||||
base.set_path(base_path);
|
||||
base.set_query(None);
|
||||
base.set_fragment(None);
|
||||
Some(base.into())
|
||||
}
|
||||
|
||||
/// Transport failures, reduced to what the connect page explains differently.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransportError {
|
||||
Refused,
|
||||
Unreachable,
|
||||
ClosedWithoutAnswer,
|
||||
Timeout,
|
||||
Tls(String),
|
||||
Dns,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
|
||||
match err {
|
||||
TransportError::Refused => ProbeOutcome::Refused,
|
||||
TransportError::Unreachable => ProbeOutcome::Unreachable,
|
||||
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
|
||||
TransportError::Timeout => ProbeOutcome::Timeout,
|
||||
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
|
||||
TransportError::Dns => ProbeOutcome::Unresolvable,
|
||||
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
|
||||
}
|
||||
}
|
||||
|
||||
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
|
||||
/// connection hint at an https-only server; over https the same close is a
|
||||
/// failed handshake with nothing more to say about it.
|
||||
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
|
||||
use std::io::ErrorKind;
|
||||
match err {
|
||||
ureq::Error::Timeout(_) => TransportError::Timeout,
|
||||
ureq::Error::HostNotFound => TransportError::Dns,
|
||||
ureq::Error::ConnectionFailed => TransportError::Refused,
|
||||
// ureq passes both a rejected certificate (rustls wraps it as
|
||||
// InvalidData during the handshake) and a failed name lookup (std's
|
||||
// own error) through as plain io errors, so they are told apart here
|
||||
// before the kinds below swallow them.
|
||||
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
|
||||
TransportError::Tls(e.to_string())
|
||||
}
|
||||
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
|
||||
TransportError::Dns
|
||||
}
|
||||
ureq::Error::Io(e) => match e.kind() {
|
||||
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
|
||||
TransportError::Refused
|
||||
}
|
||||
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
|
||||
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
|
||||
_ => TransportError::Other(e.to_string()),
|
||||
},
|
||||
// ureq's own rustls errors come from setting up the TLS client (the
|
||||
// handshake's arrive as Io above); only a certificate rejection is
|
||||
// "untrusted", anything else is reported as it is.
|
||||
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
|
||||
other => TransportError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server. Blocking: call it from a worker thread.
|
||||
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
.max_redirects(0)
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
match req.call() {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let header = |name: &str| {
|
||||
resp.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let location = header("location");
|
||||
let retry_after = header("retry-after");
|
||||
let body = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.unwrap_or_default();
|
||||
classify_response(
|
||||
&status_url,
|
||||
status,
|
||||
location.as_deref(),
|
||||
retry_after.as_deref(),
|
||||
authorization.is_some(),
|
||||
&body,
|
||||
)
|
||||
}
|
||||
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
fn status_url() -> Url {
|
||||
Url::parse("http://studio.lan:3000/api/status").unwrap()
|
||||
}
|
||||
|
||||
/// `classify_response` for the cases that need no headers.
|
||||
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
|
||||
classify_response(&status_url(), status, None, None, sent_credentials, body)
|
||||
}
|
||||
|
||||
fn redirect(request: &str, location: &str) -> ProbeOutcome {
|
||||
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
|
||||
}
|
||||
|
||||
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
|
||||
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
|
||||
classify_transport(transport_error(&err, plain_http))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_needs_success_and_version() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
200,
|
||||
false,
|
||||
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
|
||||
),
|
||||
ProbeOutcome::Ok {
|
||||
version: "1.47.0".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, "<html>hello</html>"),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":true}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_and_guards() {
|
||||
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
|
||||
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
|
||||
assert_eq!(
|
||||
classify(403, false, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
// The Host guard runs before authentication, so it wins even with credentials.
|
||||
assert_eq!(
|
||||
classify(403, true, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
|
||||
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
|
||||
}
|
||||
|
||||
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
|
||||
#[test]
|
||||
fn password_change_required_only_after_sent_credentials() {
|
||||
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
|
||||
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
|
||||
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
|
||||
// Another Codeman error code, or the text without the JSON field, is not it.
|
||||
assert_eq!(
|
||||
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_says_whether_credentials_went_along() {
|
||||
let body = "Too Many Requests, try again later";
|
||||
assert_eq!(
|
||||
classify(429, false, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(429, true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(742)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(
|
||||
&status_url(),
|
||||
429,
|
||||
None,
|
||||
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
|
||||
false,
|
||||
body
|
||||
),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_https_on_the_same_host_is_suggested() {
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}
|
||||
);
|
||||
// A base path the proxy adds stays part of the suggestion; a query does not.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/api/status",
|
||||
"https://studio.lan/codeman/api/status?from=http"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/codeman/api/status?from=http".into(),
|
||||
suggested: Some("https://studio.lan/codeman/".into())
|
||||
}
|
||||
);
|
||||
// A relative Location resolves against the probed URL; another port is fine.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/base/api/status",
|
||||
"//studio.lan:3001/base/api/status"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan:3001/base/api/status".into(),
|
||||
suggested: Some("http://studio.lan:3001/base/".into())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_redirects_are_reported_without_a_suggestion() {
|
||||
// Another host (a login gateway, say).
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/login?next=x".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same host, but no longer the status API.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/login"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/login".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// A downgrade to plain http.
|
||||
assert_eq!(
|
||||
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same scheme and port, only the path moved.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/x/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/x/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Credentials in the target are never suggested.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://u:p@studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// No Location (304), or one that does not parse: nothing to act on.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 304, None, None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 304 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 302 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_classes() {
|
||||
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
|
||||
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
|
||||
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
|
||||
assert_eq!(
|
||||
classify_transport(TransportError::Tls("bad cert".into())),
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "bad cert".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The io errors exactly as ureq hands them over (`Error::Io`).
|
||||
#[test]
|
||||
fn io_errors_are_told_apart() {
|
||||
use io::ErrorKind;
|
||||
// rustls' certificate rejection from the handshake.
|
||||
let cert = "invalid peer certificate: UnknownIssuer";
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::InvalidData, cert, false),
|
||||
ProbeOutcome::TlsUntrusted { detail: cert.into() }
|
||||
);
|
||||
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::InvalidData,
|
||||
"received corrupt message of type InvalidContentType",
|
||||
false
|
||||
),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "received corrupt message of type InvalidContentType".into()
|
||||
}
|
||||
);
|
||||
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::Other,
|
||||
"failed to lookup address information: nodename nor servname provided, or not known",
|
||||
true
|
||||
),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// The same text from the OS (an errno) is not a lookup failure.
|
||||
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
|
||||
assert!(!matches!(
|
||||
classify_transport(transport_error(&os, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
));
|
||||
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
|
||||
ProbeOutcome::MaybeHttps
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Peer disconnected".into()
|
||||
}
|
||||
);
|
||||
// No route: Local Network privacy, or a machine that is off.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
|
||||
ProbeOutcome::Refused
|
||||
);
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// A ureq TLS setup message is not an untrusted certificate.
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(
|
||||
&ureq::Error::Tls("Rustls invalid dns name error"),
|
||||
false
|
||||
)),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Rustls invalid dns name error".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_serializes_for_the_page() {
|
||||
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::NotCodeman { status: 404 }),
|
||||
serde_json::json!({"kind":"notCodeman","status":404})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60)
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::PasswordChangeRequired),
|
||||
serde_json::json!({"kind":"passwordChangeRequired"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/".into(),
|
||||
suggested: None
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unreachable),
|
||||
serde_json::json!({"kind":"unreachable"})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
|
||||
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unresolvable),
|
||||
serde_json::json!({"kind":"unresolvable"})
|
||||
);
|
||||
}
|
||||
|
||||
/// Against real sockets: a closed port is `Refused`, a listener that
|
||||
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
|
||||
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
|
||||
/// and a listener that hangs up without answering is `MaybeHttps`.
|
||||
#[test]
|
||||
fn probe_against_local_sockets() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
|
||||
|
||||
/// Accept one connection, read the request and send `response`
|
||||
/// (nothing, then close, for an empty one).
|
||||
fn serve_raw(response: String) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
// Read the whole request head (a GET has no body) so that
|
||||
// closing sends a FIN, not a reset for unread bytes.
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
|
||||
serve_raw(format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
))
|
||||
}
|
||||
|
||||
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_once("404 Not Found", "", "nope");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
|
||||
|
||||
let p = serve_once("401 Unauthorized", "", "Unauthorized");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
|
||||
|
||||
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, Some("Basic YWRtaW46eA==")),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(30)
|
||||
}
|
||||
);
|
||||
|
||||
// Nothing listens on the redirect target: had it been followed, the
|
||||
// answer would be Refused instead.
|
||||
let p = serve_once(
|
||||
"301 Moved Permanently",
|
||||
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
|
||||
"",
|
||||
);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Redirect {
|
||||
location: format!("https://127.0.0.1:{closed}/api/status"),
|
||||
suggested: Some(format!("https://127.0.0.1:{closed}/"))
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_raw(String::new());
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
|
||||
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user